プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

LINE Messaging API のアカウント連携を使ってみる

LINE Messaging API を使ってボットを作っているのですが、このボットを特定の限定したユーザーにだけ使ってほしいという要望が出てきました。 例えば、会社向けの LINE ボットを、その会社の社員のみで使うような場合です。

ボットの機能にもよるのですが、もし LINE WORKS を使ってもいいなら、なんの苦労もない(もともと特定の組織向けのサービスですからね)のですが、インターフェースに慣れている LINE を使いたいという要望は結構強いです。

もっとも、公式アカウントが未認証アカウントであれば、検索に表示されないので、まずもって関係者以外が友達登録することもないとは思いますが、念のため制限をかけておく必要があります。

ということで、 LINE のアカウント連携機能を使って、これを実現することにしました。

ユーザーアカウントを連携する | LINE Developers

LINE アカウント連携を使えば、独自に提供するアカウント(例えばメールアドレスなど)と LINE 公式アカウントと友達になっている LINE ユーザーを連携させることができます。

この連携のためには、独自のアカウントで認証(ログイン)を行う必要があります。
それにより、例えば、社員にのみ独自アカウント(とパスワード)を発行しておけば、独自アカウントでのログインができないユーザーは、仮にこのLINEボットと友達になっても、ボットの機能を使うことに制限をかけることができます。

なかなか便利そうな機能です。

このアカウント連携を試してみたので、その際のメモ書きをまとめておきます。

構成

アカウント連携の登場人物としては、

  • ユーザー
  • ボットサーバー(Webhook を受けるサーバー、公式ドキュメントの Provider's bot server)
  • 独自アカウントログイン用のWebサーバー(公式ドキュメントの Provider's web server)
  • LINE プラットフォーム

があります。これらの登場人物間のやり取りを図式化したものが、公式ドキュメントの

ユーザーアカウントを連携する | LINE Developers

にありますので、これを見るとわかりやすいかと思います。

今回は、このボットサーバーを Lambda で実装します。 また、独自アカウントの認証のために Cognito を使い、ログイン画面を S3 の静的ホスティングで提供します。連携情報は DynamoDB に保存します。 あと、ログイン成功後に、ノンス(とユーザー名)登録用 Lambda を API Gateway 経由で呼び出します。

それぞれの関係を模式的に描くとこんな感じになります。

f:id:junichim:20191016145240p:plain

独自アカウント管理とログイン処理

この部分ですが下記のサンプルとよく似ています。

blog.mori-soft.com

ログイン処理後、マイページを表示する部分は不要で、 LINE アカウント連携で指定されたURLにリダイレクトするだけになります。

今回の設定内容についてざっと触れておきます。

Cognito ユーザープール

メールアドレスおよび初期パスワードを管理者が登録する形を想定します。 また、メールアドレスでのログインとします。

f:id:junichim:20191016152149p:plain

設定後の画面はこんな感じです。

会社の社員が使うことを想定しているので、自己サインアップは許可せずに管理者のみがユーザー作成できるようにします。

f:id:junichim:20191016153702p:plain

あと、アプリクライアントIDの発行をしておきます(クライアントシークレットは生成しません)。

f:id:junichim:20191016152322p:plain

最後に、管理画面からテストユーザーを登録しておきます。 今回の設定のようにメールアドレスをユーザー名として設定する場合は、ユーザー作成時は『ユーザー名』と『Eメール』の両方にメールアドレスを入力するようにします。

f:id:junichim:20191016152704p:plain

また、仮パスワード欄が空欄だと、自動で仮パスワードが発行されます。ただし、『この新規ユーザーに招待を送信しますか?』のチェックがオフだと、仮パスワードが伝わらないので、実際問題ログインできません。仮パスワードが空欄の場合は、必ず招待を送信するようにしてください。

なお、ユーザー登録完了後の画面を見ると、ユーザー名は Cognito 側で生成される文字列となっています。

f:id:junichim:20191016152859p:plain

また、アカウントのステータスが『FORCE_CHANGE_PASSWORD』 になっています。これは、最初のログイン後、強制的にパスワードを変更する必要があることを意味しています。

詳しくは下記のリンク先をご連絡ください。

管理者としてのユーザーアカウントの作成 - Amazon Cognito

これで、準備完了です。

DynamoDB

次に、 DynamoDB を定義します。
ここで定義するテーブルは、

  • LineLinkingNonce
    アカウント連携の途中で作成するノンスとユーザー名(ここではメールアドレスをユーザー名として使います)のペアを管理するテーブル
  • LineLinkingRegistered
    各ユーザーの LINE ID とユーザー名のペアを管理するテーブル

の2つとします。 後者のテーブルにエントリーが存在していれば、連携済み、となります。

どちらのテーブルも、プライマリーキーとしてパーティションキーのみを指定します。LineLinkingNonce テーブルは nonce をキーにして、LineLinkingRegistered テーブルは lineId をキーにします(型は文字列型)。

ログイン画面

ログイン画面は前述したように過去記事で触れているので詳細は省略します。

管理者がユーザーを作成した際、最初のログイン時にパスワードを変更する必要がある部分は、下記のリファレンスを参考にして実装しました。

ウェブアプリとモバイルアプリの Amazon Cognito との統合 - Amazon Cognito

このログイン後の処理部分だけ、ちょっと触れておきます。

ログインボタンを押された後のログイン処理部分はこんな感じになります。

app.js

lineln.login = function() {
    let username = $('.inputUserName').val();
    let password = $('.inputPassword').val();
    if (!username | !password) {
        lineln.showMessage("ユーザー名およびパスワードを入力してください");
        return false;
    }

    let authenticationData = {
        Username: username,
        Password: password
    };
    let authenticationDetails = new AmazonCognitoIdentity.AuthenticationDetails(authenticationData);

    let userData = {
        Username: username,
        Pool: lineln.UserPool,
        Storage: lineln.poolData.Storage
    };

    let cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData);
    cognitoUser.authenticateUser(authenticationDetails, {
        onSuccess: function(result) {
            // リダイレクト先にユーザー名とセッション情報を渡す
            lineln.poolData.Storage.setItem(lineln.KEY_USERNAME, username);
            lineln.poolData.Storage.setItem(lineln.KEY_SESSION, cognitoUser.Session);

            lineln.backToLine();
        },
        onFailure: function(err) {
            console.log(err);
            let message_text = err.message;
            lineln.showMessage(message_text);
        },
        newPasswordRequired: function(userAttributes, requiredAttributes) {
            console.log("transit to new password challenge");
            console.log("userAttributes: " + JSON.stringify(userAttributes));
            console.log("requiredAttributes: " + JSON.stringify(requiredAttributes));
    
            // リダイレクト先にユーザー名とセッション情報を渡す
            lineln.poolData.Storage.setItem(lineln.KEY_USERNAME, userAttributes.email);
            lineln.poolData.Storage.setItem(lineln.KEY_SESSION, cognitoUser.Session);

           $(location).attr('href', '#challenge');
        }
    });
};

newPasswordRequired コールバック内で新しいパスワードの入力画面へ遷移させます(画面描画部分は割愛します)。

新しいパスワード入力後の処理はこんな感じになります。

app.js

lineln.challenge = function() {
    let newPassword = $('.inputNewPassword').val();
    let passwordConfirm = $('.inputNewPasswordConfirm').val();
    if (!newPassword | !passwordConfirm) {
        lineln.showMessage("パスワードを入力してください");
        return false;
    }
    if (newPassword != passwordConfirm) {
        lineln.showMessage("パスワードが一致しません");
        return false;
    }

    // パラメータの取得
    let param = $(location).attr('search');
    console.log('param   : ' + param);

    let username = lineln.poolData.Storage.getItem(lineln.KEY_USERNAME);
    if (! username) {
        lineln.showMessage("ユーザー名が不明です。再度ログインからやり直してください。");
        lineln.storageClear();
        lineln.transitDelay('#login', lineln.DELAY_TIME);
        return false;
    }
    let session = lineln.poolData.Storage.getItem(lineln.KEY_SESSION);
    if (! session) {
        lineln.showMessage("セッション情報が不明です。再度ログインからやり直してください。");
        lineln.storageClear();
        lineln.transitDelay('#login', lineln.DELAY_TIME);
        return false;
    }

    let userData = {
        Username: username,
        Pool: lineln.UserPool,
        Storage: lineln.poolData.Storage
    };

    let cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData);
    cognitoUser.Session = session;
    cognitoUser.completeNewPasswordChallenge(newPassword, {}, {
        onSuccess: function(result) {
            lineln.backToLine();
        },
        onFailure: function(err) {
            console.log(err);
            let message_text = err.message;
            lineln.showMessage(message_text);
        }
    });
};

cognitoUser.completeNewPasswordChallenge を呼び出せばOKです。 呼び出しに成功したあとは、ノンスを生成して、ノンス登録用のLambda呼び出しを行います。

app.js

lineln.backToLine = async function() {
    let username = lineln.poolData.Storage.getItem(lineln.KEY_USERNAME);
    lineln.ShaObject.update(username); // TODO 日付も追加?
    let nonce = lineln.ShaObject.getHash("HEX");

    console.log("nonce: " + nonce);

ノンスは、ユーザー名のハッシュを求める形で実装してみました。

呼び出し処理は、こんな感じです。

    // Dynamo DB に email と ノンスを記述
    try {
        await lineln.preRegister(username, nonce);
    } catch(err) {
        console.error("backToLine error: " + JSON.stringify(err));
        lineln.handleError(err);
    }

preRegister 内では、Lambda 呼び出しに必要なトークン等を取得して、API Gateway のURLをたたいています。

呼び出し処理が問題なく完了したら、LINE指定のアドレスにリダイレクトします。

    // 呼び出し元が LINE かそれ以外かで分ける
    let token = lineln.poolData.Storage.getItem(lineln.KEY_LINKTOKEN);
    if (token) {
        $(location).attr('href', 'https://access.line.me/dialog/bot/accountLink?linkToken=' + token + "&nonce=" + nonce);
    } else {
        $(location).attr('href', 'https://www.mori-soft.com');
    }

};

このサンプルでは、linkTokenがない場合は、適当なWebページに飛ばしてますが、実際には不要な処理ですね。

次はノンス登録で呼ばれる Lambda 側です。

ノンスの登録用 Lambda

ログイン成功後に、ノンス登録用 Lambda を API Gateway 経由で呼び出します。 この API Gateway の設定では認可処理を Cognito で行うことで、ログインしていないユーザーが呼び出せないようにしておきます。

処理自体はDynamoDBに対して、ノンスとemailのペアを登録するだけです。

lineLinking.js

/**
 * LINEアカウント連携のため、ユーザー情報とノンスを保存する
 * API Gateway (Cognitoによる認可) 経由で呼び出される
 */
exports.preregister = async function(event) {
    console.log("start");
    console.log(JSON.stringify(event));
    
    // 受信メッセージの確認
    const body = JSON.parse(event.body);
    console.log("body: " + JSON.stringify(body));

    if (!body.nonce || !body.email) {
        handleError(null, "nonce and email must be filled.");
    }

    try {
        await putNonce(body.nonce, body.email);
    } catch(err) {
        handleError(err,"replay message failed");
    }

    const response = {
        statusCode: 200,
        headers: {"Access-Control-Allow-Origin": "*"},
        body: JSON.stringify('success'),
    };
    return response;
};

async function putNonce(nonce, email) {
    let param = {
        TableName: dbtable.nonceTable,
        Item: {
            'nonce': nonce,
            'email': email,
        }
    };
    console.log("putNonce param: " + JSON.stringify(param));
    return dbaccess.putItemToDb(param);
}

LINE からの Webhook

LINE developer コンソールでボットを作成し、 webhook を設定しておきます。

webhook の URL は Lambda 統合を行った API Gateway になります。この API Gateway の設定内容は省略しますが、 webhook の送信先となる API では Cognito による認可の設定は行いません。

Lambda

webhook から呼び出される Lambda 関数のロールは、

f:id:junichim:20191016224759p:plain

のように、DynamoDBへの書き込み権限も持つようにしておきます。

DynamoDB への書き込み権限まで与えて大丈夫なのか?と疑問に思ったのですが、(今のところの理解としては)大丈夫そうです。

というのも、webhook に送られるメッセージは、正当なメッセージであることを署名を使って検証することが可能です。 この署名の検証ではチャンネルシークレットを秘密鍵として使い、LINEプラットフォーム以外から不正なメッセージが送られた場合に、そのメッセージをはじくことができます。

ですので、Lambda の実行ロールの権限に DynamoDB を加えていても問題ないと思います(このサンプルでは FullAccess にしていますが、もちろん可能な限り権限は狭めたほうがいいです)。

署名の検証

webhook 先の Lambda の処理では、まず受け取ったメッセージの署名を検証します。

index.js

const crypto = require("crypto");

/**
 * LINE Webhook からの呼び出し関数
 */
exports.handler = async (event) => {
    console.log("start");
    console.log(JSON.stringify(event));
    
    // メッセージチェック
    const body_str = event.body;
    const signiture = event.headers["X-Line-Signature"];

    if (! isValidMessage(body_str, signiture)) {
        console.warn("message is invalid");
        throw new Error("message is inivalid. maybe falsification.");
    }
(略)

function isValidMessage(body, signiture) {
    const hmac = crypto.createHmac("sha256", CHANNEL_SECRET);
    hmac.update(body);
    const str = hmac.digest('base64');

    console.log("message body: " + body);
    console.log("message hmac: " + str);
    console.log("signiture   : " + signiture);

    return str === signiture;
}

ちなみに、この部分の処理は

LINE WORKS で予約投稿ボットを作成する - プログラマーのメモ書き

の署名の検証と同じです。

振り分け

次に、受け取ったイベントに応じた処理を行います。イベントタイプが message の時はアカウント連携済みか否かを確認し、連携処理に移行します。 既に、アカウント連携済みであれば、送られたメッセージをオウム返しします。

index.js

    // イベントでの振り分け
    const evt = body.events[0]; // TODO 複数イベントへの対応
    switch (evt.type) {

        (略)

        case "message":
            // 連携済みか否か確認?
            // LINEアカウント連携の確認
            let result;
            try {
                result = await linking.isAlreadyLinked(evt.source.userId);
            } catch(err) {
                handleError(err, "cannot check already linked or not");
            }
            console.log("アカウント連携済み: " + result);

            if (result) {
                if (evt.message.type === 'text' && evt.message.text === "解除") {
                    // 連携解除
                    try {
                        await linking.unregister(evt.source.userId);

                        await accountLinkingMessage(evt.replyToken, "アカウント連携を解除しました");
                        console.log("アカウント連携解除: " + evt.source.userId);
                    } catch(err) {
                        handleError(err,"unregister failed");
                    }
                } else {
                    // 返信
                    try {
                        await repeatMessage(evt.replyToken, evt.message);
                    } catch (err) {
                        handleError(err,"replay message failed");
                    }
                }
            } else {
                // 連携するようにメッセージを返信
                try {
                    let linkToken = await requestLinkToken(ACCESS_TOKEN, evt.source.userId);
                    console.log("linkToken: " + linkToken);
                    await sendLinkMessage(evt.replyToken, linkToken);
                } catch(err) {
                    handleError(err,"request linkToken failed");
                }

            }
            break;

なお、『解除』とだけ書いたメッセージを送った場合は、アカウント連携を解除します。

一方、受け取ったイベントが accountLink の場合は、アカウント連携の成否に応じた処理を行います。

index.js

    // イベントでの振り分け
    const evt = body.events[0]; // TODO 複数イベントへの対応
    switch (evt.type) {

        (略)

        case "accountLink":
            console.log("アカウント連携、ユーザー: " + evt.source.userId);
            console.log("アカウント連携、成否: " + evt.link.result);

            if (evt.link.result === "ok") {
                try {
                    // DynamoDB への登録
                    await linking.register(evt.link.nonce, evt.source.userId);

                    // メッセージ送信
                    await accountLinkingMessage(evt.replyToken, "アカウント連携が完了しました");
                } catch(err) {
                    handleError(err,"failed register to DynamoDB");
                }
            } else {
                // 連携失敗
                try {
                    // メッセージ送信
                    await accountLinkingMessage(evt.replyToken, "アカウント連携が失敗しました。もう一度やり直してください。");
                } catch(err) {
                    handleError(err,"failed account linking");
                }
            }
            break;
        default:
            console.log("未対応イベント発生: " + evt.type);
    }

動作確認

ここまで実装できたら、テストしてみましょう。

まずは、 LINE Developer の画面に表示されている QR コードを読み込んで友達登録をします。

f:id:junichim:20191016162017p:plain

自動応答のメッセージが返ってきています。
続いて、何かメッセージを送ります。

f:id:junichim:20191016162107p:plain

この時点では、まだアカウント連携を行っていないので、アカウント連携のための独自アカウントのログイン画面へのリンクを持ったボタンを持つメッセージが返ってきました。これをクリックすると、

f:id:junichim:20191016162238p:plain

f:id:junichim:20191016162254p:plain

とログイン画面が表示されます。

メールアドレスと仮パスワードを入力すると、

f:id:junichim:20191016162334p:plain

新しいパスワードの入力画面が表示されます。 問題なくパスワードの設定が終わると、

f:id:junichim:20191016162417p:plain

のように、アカウント連携が完了した旨の画面に遷移します。 また、 LINE 上でもアカウント連携が完了した旨のメッセージを送っています。

f:id:junichim:20191016162450p:plain

アカウント連携完了後に、メッセージを送ると、

f:id:junichim:20191016162553p:plain

アカウント連携画面は出てこずに、オウム返しで送ったメッセージが表示されました。

実際に、DynamoDBを見てみると、ノンスとemailのペア、lineIdとemailのペアがちゃんと登録されていました。

f:id:junichim:20191016162829p:plain

f:id:junichim:20191016162732p:plain

一応、これでアカウント連携ができるようになりました。

また、『解除』とだけメッセージを送れば、アカウント連携が解除されます。

f:id:junichim:20191016225716p:plain

サンプルとして作ってるので、実装が今一つのところも多々ありますが、これでアカウント連携の機能を試すことができました。

なお、関連するソースコード一式を GitHub に置いておきますので、ご興味があればご覧ください。

github.com

参考

今回は ログイン画面を自分で用意しましたが、これって結構面倒ですよね?

元々、 Cognito には組み込み UI というのが用意されていて、それを使うことで認証処理を行うことができます。

サインアップおよびサインインに Amazon Cognito のホストされた UI を使用する - Amazon Cognito

ただ、この組み込み UI の表示言語が英語のみで、他言語に変更できないようです。

もし、ログイン画面が英語でも構わないような場合は、こちらを使って実現するほうが簡単かもしれません。
ただ、Cognito の設定画面を見ている印象として、ログイン後のリダイレクト先に linkToken や nonce などのパラメータをうまく渡せるかどうかが気になっていますので、果たしてうまく使えるかはちょっとわかりません。

人柱になりたい方、ぜひ試してみてください。

POP3 を IMAP4 に移行

Rainloop も導入したし、メールサーバー側で振り分けもできる目処が立ったので、既存のメールアカウントについて、 POP3 でローカルに保存しているメールを、 IMAP4 でアクセスできるようにサーバー側に移動させたいと思います。

そのうえで、 Rainloop ですべてのメールにアクセスできるようにしたいと思います。

Becky!

いままでメーラとしては Becky! Internet Mail Ver2 を使っていました。 Becky! 自体は IMAP でのアクセスも可能です。 なので、Becky! 上で POP3 のメールボックスと並んで IMAP4 用のアカウントを設定してやり、フォルダをコピーすれば、IMAP4側(サーバー側)にコピーできるんじゃないかと思いました。

が、甘かった。 実際試してみると下記のようなエラーがでます。

f:id:junichim:20191004234447p:plain

調べてみると、 Becky! では IMAP4 アカウントに対して、メールのコピーはできるのですが、

フォルダのコピーはできないようです。

ちなみに、下記リンク先の記事だとできるっぽいけど、IMPA4アカウント配下のローカルフォルダにコピーしてるだけなので、サーバー側にはコピーされてませんのでご注意を。

POP3からIMAP4への移行について | 慶應義塾 湘南藤沢ITC

移行方法

メールの数が少なければ、フォルダ毎に保存されているメールをコピーしてもいいんですが、長年使ってきたんでメールもフォルダも山のようにあります。 さて、どうしたもんか?と思って調べていると、 Thunderbird だと POP3 で保存しているフォルダ丸ごと IMAP4 側にコピーできることがわかりました。

POP アカウントを IMAP アカウントに切り替える | Thunderbird ヘルプ

それなら、若干手間がかかりますが、一旦、PC上で Becky! から Thunderbird にコピーして、その後 Thunderbird 上で POP3 から IMAP4 側にコピーするということで、なんとかなりそうです。こんな感じですね。

Becky! 2 (POP3) -> Thunderbird (POP3) -> Thunderbird (IMAP4)

では、早速、試してみます。

Becky! 2 (POP3) から Thunderbird (POP3) へメールデータをコピー

この操作ってメーラの移行と同じなので、メーラの移行で調べてみると、多くの記事がヒットします。

どれもだいたい似たような手順になってます。 上記の元記事とほとんど同じですが、備忘録も兼ねて書いておきます。

Becky! 側でのデータのエクスポート

CircleBecky プラグインのインストール

まず、 CircleBecky というプラグインをインストールします(ver 1.9.6)。 Vector からダウンロードして、落としてきたファイルをダブルクリックして、インストールします。

Windows 10 だと、最初はエラーが出ます(スクリーンショット取り損ねました)。 そのあと、もう一度起動すると、管理者権限で実行するか尋ねられるので、管理者としてインストールを進めると問題なくインストールできました。

インストール時には、プラグインをどこにインストールするのか聞かれるので、Becky!のプログラムフォルダ(C:\Program Files (x86)\RimArts\B2\PlugIns)を指定しておきます。

次に、 Becky! を起動すると、起動時にプラグインがインストールされたので、有効にするか否かを尋ねるダイアログが表示されるので、有効にします。

Becky! 起動後、『ツール』->『プラグインの設定』->『CircleBecky Plug-in』を選択します。

f:id:junichim:20191004231235p:plain

『拡張エクスポート』で eml が選択され、『閲覧用のHTMLを生成』のチェックをオフにしておきます。

f:id:junichim:20191004231423p:plain

これで、準備完了です。

Becky! 上のフォルダをエクスポート

IMAP4 に取り込みたい、 Becky! 上のフォルダ(サブフォルダを含んでいても可)を選択し、

f:id:junichim:20191004231657p:plain

『ファイル』->『フォルダ』->『拡張エクスポート』を選択します。

f:id:junichim:20191004231750p:plain

出力先のフォルダ選択画面が出てくるので、適当なフォルダを選択します。

メールの数が多いと時間がかかりますが、少なければすぐに終わります。

f:id:junichim:20191004231956p:plain

このようなダイアログがでて、先ほど指定したフォルダ以下にエクスポートされていれば終わりです。

なお、メールが一通も含まれないフォルダはエクスポートされませんでした。なので、フォルダもエクスポートしたいなら、最低でも1通はメールを含めるようにしてください(サブフォルダにメールが入っている形でも構いません)。

Thunderbird 側でのインポート

次に、 Thunderbird 側の作業です。

Thunderbird がインストールされていなければ、インストールします(作業時点で 68.1.1 )。

続いて、アドオンをインストールします。画面の右端にあるハンバーガーメニューをクリックして、アドオンを選びます。

f:id:junichim:20191004232315p:plain

アドオンの画面が表示されたら、『拡張機能』を選択し、検索窓より『ImportExportTools』と入力します。

f:id:junichim:20191004232436p:plain

検索結果の『ImportExportTools NG』を選んで、『Thunderbird へ追加』をクリックしてインストールします。

ちなみに、『ImportExportTools』はこのバージョンのThunderbirdではサポートされていないようで、インストールができません。

POP3 アカウントを設定

続いて、Becky! のメールを移行する POP3 アカウントを設定します。アカウントの設定手順は省略します。 実は、このアカウントではサーバーにアクセスはしないので、ダミーのアカウントでも問題ありません。

ちなみに、POP3 でサーバーにアクセスしないなら、直接 IMAP4 アカウントのメールボックスに対して、インポートをすればいいんじゃないか?と思われると思います。実際、そう思って、試してみたら、なぜか正しくインポートされませんでした。世の中甘くないですね。

Becky! のメールのインポート

この設定したアカウントに対して、Becky! のメールをインポートします。
受信トレイ上で右クリックして、『ImportExportTools NG』->『フォルダからすべてのemlファイルをインポート』->『サブフォルダも含む』を選択します。

f:id:junichim:20191004233019p:plain

フォルダ選択画面が表示されるので、先ほどBecky! でエクスポートしたフォルダを指定します。 問題がなければ、サブフォルダごとインポートされます。 なお、インポート時点ですべて未読メールになっています。

ちなみに、指定したフォルダが、受信トレイ(右クリックした場所)に対応します。なので、エクスポートしたフォルダ名も階層として取り込みたいときは、

  • Becky!で指定したフォルダの一つ上のフォルダを指定するか
  • Thunderbird側でフォルダを作成し、そのうえでインポート

を行うようにする必要があります。

Thunderbird 側での POP3 -> IMAP4 への移動

ここまでできれば、残りはあと少しです。

Thundirbird に IMAP4 でアカウントをセットアップします。

このIMAP4アカウントに対して、先ほど Becky! からインポートした Thunderbird 上の POP3 アカウント上のフォルダをドラッグアンドドロップしてコピーします。

f:id:junichim:20191004234151p:plain

メール数が多いと時間がかかりますが、操作が完了すればOKです。

これで、無事に IMAP4 上で今までのメールにアクセスできるようになりました。

注意点

実は、上記の方法で完璧に移行できる、というものではありません。

というのも、こちらの記事でも触れらているように、Becky! からすべてのメールデータをエクスポートしたはずのものが、すべてエクスポートされておらずに、一部のメールが欠けているという状態になったためです。

ただ、多くのフォルダでメールの欠けはないのですが、時々かけているフォルダがあります。 ちょっと見てみましたが、結局、これの発生原因も発生条件もよくわかりませんでした。

とはいえ、多くのフォルダがフォルダ構成を保ったままコピー処理できるのは魅力的です。なので、実際には

  1. 上記手順により Becky! (POP3) -> Thunderbird (POP3) へ移行
  2. フォルダ単位で、メール総数を比較
  3. メール総数が一致していれば、そのまま Thunderbird (POP3) -> Thunderbird (IMAP4) へコピー
  4. メール総数が一致していなければ、 Becky! 上に IMAP4 のメールボックスを作成し、フォルダ単位でメールをコピー

としました。 これだと、すべてBecky! 上で対応するよりは楽になります。

あー、疲れた。
とりあえず、これで1つ目のメールアドレスは移行できました。ほかのメールアドレスについても、あとは時間を見つけてボチボチ作業をしていきたいと思います。

Rainloop の導入

メールを IMAP に移行した場合、今までのようにメールクライアントからアクセスするのもありですが、せっかくなので、ブラウザベースでどこからでも見れるようにしたいと思います(メール提供者のWebメール使えばできますよね)。
そうなると、アカウント毎にWebメール画面にログインして、アクセスすることになりますが、全部のメールアドレスのパスワードなんて覚えられません。

こういう場合、よくある手は、 gmail を使って一か所で見れるようにするんでしょうが、なんとなく gmail は使いたくありません。

昔は gmail って、メールをスキャンして広告を出していた、というのが引っかかっていたんですが、今はそれももうないはずです(こちらの記事なご参考に)。

なので、本当は気にしなくていいんでしょうが、我ながらまだそれを引きづってるんでしょうか?なんとなく気になります。
非合理かもしれませんが嫌なものは嫌なんで、別の方法を考えます。

で、かなりあれこれ調べて、得られた結論としては、Webメールのソフトウェアをサーバーに入れて使おう、という形になりました(いろいろと調べた顛末はまた別途まとめます)。この時、はずせない機能としては、

  • 1度のログインで、複数のアカウントのメールを見ることができる

というものです。 最終的に選んだのは、 Rainloop という Webメールソフトウェアです。

www.rainloop.net

いまどきっぽい UI で、良さそうです。
早速、インストールして試してみたいと思います。

rainloop インストール

試用ということもあり、サーバーは EC2 のスポットインスタンスを使って立ち上げました。OS は Ubuntu 18.04 Server 版です。 apache2 / php 5.4 が動くようにしておきます。

System Requirements / Documentation / RainLoop Webmail

サーバーセットアップの詳細は、後ろに参考として書いておきますので、必要があればご覧ください。

rainloop インストール

公式の手順に従って作業すれば、インストールは非常に簡単です。

wget https://www.rainloop.net/repository/webmail/rainloop-community-latest.zip

sudo mkdir /var/www/html/rainloop
sudo unzip rainloop-community-latest.zip -d /var/www/html/rainloop/

パーミッション変更

cd /var/www/html
sudo chown -R www-data:www-data rainloop/

あと、インストールの際に作られた rainloop/data ディレクトリを保護する必要があります(ここに設定などがすべて入ります)。
公式のインストールの説明には、 Apache の場合、 .htaccess で対応しろとあります。 が、 rainloop/data に .htaccess がインストールされなくなったみたいです。

上記の2つ目にあるように conf ファイルを作成して対応します。

cd /etc/apache2/sites-available
sudo vi 000.conf
sudo a2ensite 000
sudo systemctl restart apache2

000.conf の内容はこちら

<Directory "/var/www/html/rainloop/data" >
    Require all denied
</Directory>

インストールはこれで終わりです。 サーバー準備するほうが手間がかかりましたね。

試用

公式のRrainloop の設定方法は以下にありますので、そちらを参考にして設定と試用してみます。

Documentation / Configuration / RainLoop Webmail

全体設定

まず、http://サーバーのURL/rainloop/?admin にアクセスすると管理画面が表示されます。

f:id:junichim:20191009114447p:plain

ユーザー名 admin, パスワード 12345 でアクセスするとログインできます。 ちなみに、dataディレクトリがアクセス可能になっている場合、下記のような警告画面が表示されます。

f:id:junichim:20191009113655p:plain

ログイン後、言語表示を変更すれば、日本語で表示できます。

f:id:junichim:20191009114611p:plain

Rainloop は管理者以外は独自のユーザーを持っていません。 Rainloop にログインする際のユーザー名は、メールアドレスとそのメールサーバーへアクセスするためのパスワードになります。

この時、どんなメールアドレスでも使えるのではなく、 Rainloop にドメインが登録されているメールアドレスを使います。 インストール直後は、gmail のみがドメインとして有効になっています。

f:id:junichim:20191009115311p:plain

なので、例えば、 gmail がドメインに登録されているので、自分以外の知らない誰かがログインすることも可能になります(まあ、やらないと思いますが)。

さて、ここにドメインを追加することで、独自ドメインのメールアドレス等も扱えるようになります。

f:id:junichim:20191009115728p:plain

設定画面はこんな感じです。必要事項を記入して、『追加』ボタンを押せば完了です。

あとは、管理者アカウント名とパスワードの変更を行っておきます。

f:id:junichim:20191009115932p:plain

ログイン

全体設定が終わったら、ログイン画面に移動します。

http://サーバーのURL/rainloop

にアクセスするとログイン画面が表示されます。

f:id:junichim:20191009120115p:plain

地球儀のアイコンをクリックすると、表示言語を変更することもできます。

f:id:junichim:20191009120207p:plain

ここでは、先ほど言ったように、登録済みのドメインのメールアドレスとパスワードでログインします。

ログインが成功すれば、メールが表示されます。

f:id:junichim:20191009120759p:plain

このメールアドレスでログインした状態で、他のメールアカウントを見るためには、右上にある人型のアイコンをクリックして、『アカウントを追加』を選びます。

f:id:junichim:20191009121015p:plain

メールアドレスとパスワードが求められるので、入力して『追加』を押します。

f:id:junichim:20191009121047p:plain

問題なく追加できると、このように複数のメールアドレスが表示されます。

f:id:junichim:20191009121302p:plain

この状態で、他のメールアドレスを選択すると、そのメールアドレスに対するメール一覧に切り替わって表示されます。

f:id:junichim:20191009121501p:plain

最初にログインしたメールアドレスのパスワードだけ覚えておけば、これでスマホも含めてどこからでもメールに快適にアクセスできます。

これはなかなか便利そうですね。

gmail について

なお、gmail でログインしようとすると単にパスワードを入れただけだとログインできないことがあります。 これは、gmail アカウントに対して二段階認証が有効になっているような場合です。

このような場合は、下記の手順に従って、アプリパスワードを発行します。

support.google.com

詳しくは上記の手順を読めばわかりますが、アプリパスワードは発行時に利用可能なGoogleのサービスを指定します。 gmailなら『メール』となります。このため、アプリパスワードを使った場合、それを発行する際に許可されたアプリ以外にはアクセスできません。

Rainloop のログイン時には、上記で『メール』を選択して発行したアプリパスワードをログイン時のパスワードに入力すればログインできるようになります。

まとめ

まだ本格的に使ってはいませんが、かなり便利に使えそうです。

一点注意しないといけないところがあります。
Rainloop 自体はメールそのものを保存せずに、一種の Web メールクライアント(という言い方あるのかな?)として振舞います。なので、 Rainloop 側にはアプリケーションの設定情報、メールアドレスやパスワードといった情報が含まれているだけです。

メールそのものは、各メールアドレスのメールボックス(メールサーバー側)に残ったままとなります。なので、メールのバックアップ等が必要な場合は、それぞれのメールサーバー側でバックアップを行ってください。

参考

サーバーのセットアップ

前述したように、サーバーは EC2 のスポットインスタンスを使って立ち上げました。OS は Ubuntu 18.04 Server 64bit 版です。 インスタンスが起動したら、各種設定を行っていきます。

ロケールを日本語に

sudo apt install language-pack-ja
sudo update-locale LANG=ja_JP.UTF-8

タイムゾーン変更

sudo timedatectl set-timezone Asia/Tokyo

Ubuntu16.04でtimezoneを変えたい - ふり返る暇なんて無いね

日本語パッケージの追加

wget -q https://www.ubuntulinux.jp/ubuntu-ja-archive-keyring.gpg -O- | sudo apt-key add -
wget -q https://www.ubuntulinux.jp/ubuntu-jp-ppa-keyring.gpg -O- | sudo apt-key add -
sudo wget https://www.ubuntulinux.jp/sources.list.d/bionic.list -O /etc/apt/sources.list.d/ubuntu-ja.list
sudo apt update
sudo apt upgrade
sudo apt-get dist-upgrade

Ubuntuの日本語環境 | Ubuntu Japanese Team

NTP サーバーの切り替え

cd /etc/systemd/
sudo cp -p timesyncd.conf timesyncd.conf.org
sudo vi timesyncd.conf
NTP=ntp.nict.jp
sudo systemctl restart systemd-timesyncd.service

【Ubuntu 18.04 LTS Server】時刻の同期について | The modern stone age.

apache2 / php

ここから apache2 / php をインストールして設定していきます。

sudo apt install apache2
sudo apt install php libapache2-mod-php
sudo apt install php-curl
sudo apt install php-xml  # DOM
sudo apt install unzip

apache2 の設定を変更します。 セキュリティ周りは下記記事を元に作業しています。

Ubuntu 18.04 + LAMP 環境構築 (2) – Apache 2.4 | 雑廉堂の雑記帳

Index, FollowSymLink を無効

ubuntu@ip-172-30-0-235:/etc/apache2$ diff apache2.conf.org apache2.conf
171c171
<       Options Indexes FollowSymLinks
---
>       Options -Indexes -FollowSymLinks
ubuntu@ip-172-30-0-235:/etc/apache2$ 

Apache2, OS のバージョン情報の隠ぺい、クリックジャッキング対策

ubuntu@ip-172-30-0-235:/etc/apache2/conf-available$ diff security.conf.org security.conf
25c25
< ServerTokens OS
---
> #ServerTokens OS
26a27
> ServerTokens Prod
35,36c36,37
< #ServerSignature Off
< ServerSignature On
---
> ServerSignature Off
> #ServerSignature On
70c71
< #Header set X-Frame-Options: "sameorigin"
---
> Header set X-Frame-Options: "sameorigin"
ubuntu@ip-172-30-0-235:/etc/apache2/conf-available$ 
sudo a2enmod headers

mod_evasive の導入

sudo apt install libapache2-mod-evasive
(途中でメールの設定を求められたら、そのままを選択)

/etc/apache2/mods-available/evasive.conf を編集

<IfModule mod_evasive20.c>
    DOSHashTableSize    3097
    #DOSPageCount        2
    DOSPageCount        5
    DOSSiteCount        50
    DOSPageInterval     1
    DOSSiteInterval     1
    DOSBlockingPeriod   10

    #DOSEmailNotify      you@yourdomain.com
    #DOSSystemCommand    "su - someuser -c '/sbin/... %s ...'"
    DOSLogDir           "/var/log/mod_evasive"

    DOSWhitelist        127.0.0.1
</IfModule>

※ DOSWhitelist はテスト後に追記

Rainloop をいろいろと触ってて、 DOSPageCount 2 だとしょっちゅうトラブルがあったので、制限を緩めてます。

ログディレクトリ作成

sudo mkdir /var/log/mod_evasive
sudo chown www-data. /var/log/mod_evasive

Apache2の再起動後、テスト

Apache DoS対策 mod_evasive の導入 - Qiita

上記サイトの方法でテストしてブロックされることを確認

mod_security(WAF)の導入

sudo apt install libapache2-mod-security2
sudo cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf

modsecurity.conf の編集

ubuntu@ip-172-30-0-235:/etc/modsecurity$ diff modsecurity.conf-recommended modsecurity.conf
7c7,8
< SecRuleEngine DetectionOnly
---
> #SecRuleEngine DetectionOnly
> SecRuleEngine On
183c184,185
< SecAuditEngine RelevantOnly
---
> #SecAuditEngine RelevantOnly
> SecAuditEngine Off
ubuntu@ip-172-30-0-235:/etc/modsecurity$ 

とりあえずルールセットはデフォルトのままとして、問題があれば見直すこととする。 Apache2再起動後、テスト

f:id:junichim:20191009141626p:plain

SQLのクエリパラメータをつけたら、403 Forbbiden になってるのでとりあえずOK。

SSL 証明書の取得

Let's Encrypt の証明書を入手して、SSLを設定します。

Ubuntu 18.04 LTS : SSL証明書を取得する(Let's Encrypt) : Server World

まずは、SSL 証明書の入手

ubuntu@ip-172-30-0-235:~$ sudo apt install certbot

ubuntu@ip-172-30-0-235:~$ sudo certbot certonly --webroot -w /var/www/html -d 証明書を発行するドメイン名
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator webroot, Installer None
Enter email address (used for urgent renewal and security notices) (Enter 'c' to
cancel): メールアドレスを入力

-------------------------------------------------------------------------------
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf. You must
agree in order to register with the ACME server at
https://acme-v01.api.letsencrypt.org/directory
-------------------------------------------------------------------------------
(A)gree/(C)ancel: a

-------------------------------------------------------------------------------
Would you be willing to share your email address with the Electronic Frontier
Foundation, a founding partner of the Let's Encrypt project and the non-profit
organization that develops Certbot? We'd like to send you email about EFF and
our work to encrypt the web, protect its users and defend digital rights.
-------------------------------------------------------------------------------
(Y)es/(N)o: n
Obtaining a new certificate
Performing the following challenges:
http-01 challenge for 証明書を発行するドメイン名
Using the webroot path /var/www/html for all unmatched domains.
Waiting for verification...
Cleaning up challenges

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/証明書を発行するドメイン名/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/証明書を発行するドメイン名/privkey.pem
   Your cert will expire on 2020-01-06. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
 - Your account credentials have been saved in your Certbot
   configuration directory at /etc/letsencrypt. You should make a
   secure backup of this folder now. This configuration directory will
   also contain certificates and private keys obtained by Certbot so
   making regular backups of this folder is ideal.
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

ubuntu@ip-172-30-0-235:~$

Let's Encrypt の証明書は有効期限が90日なので、残り30日を切ったら更新処理を行う必要があります。 でも、パッケージをインストールすると、/etc/cron.d に更新スクリプトも一緒にインストールされます(多分ちゃんとどうさするよね?)。

証明書の更新について Let'sEncryptの取得&自動更新設定してみた(CentOS7.1&Apache2.4.6) - Qiita

https 設定

下記に従って、SSL 設定を行います。

Ubuntu 18.04 LTS : Apache2 : SSLの設定 : Server World

/etc/apache2/sites-available/default-ssl.conf 設定ファイル

               SSLCertificateFile      /etc/letsencrypt/live/証明書を発行するドメイン名/cert.pem
               SSLCertificateKeyFile   /etc/letsencrypt/live/証明書を発行するドメイン名/privkey.pem
               SSLCertificateChainFile /etc/letsencrypt/live/証明書を発行するドメイン名/chain.pem

SSL を有効にする

ubuntu@ip-172-30-0-235:/etc/apache2/sites-available$ sudo a2ensite default-ssl
Enabling site default-ssl.
To activate the new configuration, you need to run:
  systemctl reload apache2
ubuntu@ip-172-30-0-235:/etc/apache2/sites-available$

ubuntu@ip-172-30-0-235:/etc/apache2$ sudo a2enmod ssl
Considering dependency setenvif for ssl:
Module setenvif already enabled
Considering dependency mime for ssl:
Module mime already enabled
Considering dependency socache_shmcb for ssl:
Enabling module socache_shmcb.
Enabling module ssl.
See /usr/share/doc/apache2/README.Debian.gz on how to configure SSL and create self-signed certificates.
To activate the new configuration, you need to run:
  systemctl restart apache2
ubuntu@ip-172-30-0-235:/etc/apache2$

http -> https リダイレクト

最後に http でのアクセスを https にリダイレクトするようにします。

rewrite エンジンを有効にします。

ubuntu@ip-172-30-0-235:/etc/apache2$ sudo a2enmod rewrite
Enabling module rewrite.
To activate the new configuration, you need to run:
  systemctl restart apache2
ubuntu@ip-172-30-0-235:/etc/apache2$

/etc/apache2/sites-available/000-default.conf を編集して、下記を追記

         RewriteEngine On
         RewriteCond %{HTTPS} off
         RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

Apache2 を再起動しておく

http でアクセスして、 https にリダイレクトされればOK

とりあえずは以上で設定終わりです。