RMSでメールアドレス検索するThunderbirdのWebExtensions版アドオンを作った話 その1

スタッフブログ

皆様どうも、こんにちは!
こまりの自称ソフトウェアエンジニア、桑木です。

弊社では通販事業も営んでいることはご存知のことかと思いますが、自社で運営するサイト以外にも楽天やYahoo!ショッピングなどのECモールにも出店しています。

今回は、楽天のお客様から届いたメールをメールソフトのThunderbirdで見ている時に、楽天の管理ページ(RMS)でお客様の注文情報を検索するThunderbirdのアドオンを作成したので、そこで調べた技術的な事柄について紹介していきます。

具体的な事例としてはニッチですが、今回使用したそれぞれの技術は色々と応用できそうなので記事にしておきます。技術的には、WebExtensions(Native messaging), nexe, WiX, userjs(Tampermonkey)を使用しました。

作成に至った背景

楽天に限った話ではありませんが、お客様とメールで問い合わせ対応等をすることがあります。楽天の場合は「楽天あんしんメルアド」というサービスを経由するので、純粋にメールだけでやり取りするとは限りませんが、その場合でもCCがショップのメールアドレスに届きます。

問い合わせが来たら、そのお客様は購入前なのか購入後なのか、購入後だとしたら何を購入したのかといった状況を把握しなければ対応できません。メールの文面に注文番号などが記載されていなければ、メールアドレスから注文を検索する必要があります。楽天ではRMSというウェブブラウザで操作するショップ管理システムがあるので、その注文検索画面にメールアドレスを入力して検索することになります。

もっともこの手のBtoCを営む場合、個別の問い合わせ管理に専用のサービスやソフトを使用することは多いと思います。弊社では一部Thunderbird(Mozilla製のメールソフト)を使用している関係で、今回のアドオンを作ってみました。

技術的概要

今回作成のアドオンで実現した動作は、Thunderbirdでメールを見ている状態から1クリックするだけで、デフォルトブラウザでRMSを開いてメールアドレスによる注文検索を実行する、という単純なものです。

Thunderbirdは内部的にブラウザを搭載していて、任意のURLをThunderbirdのタブとして開くことができます。しかし、メールソフトがThunderbirdだとしても普段RMSにログインして操作しているブラウザは別なので、OSに設定したデフォルトブラウザで開きたいところです。

これを実現するには、

  1. ThunderbirdからURLを別アプリケーションに伝える
  2. 別アプリケーションではOSのデフォルトブラウザでURLを開く
  3. ブラウザでメールアドレス検索を実行する

という3段階の手順を踏みます。

この時、メールアドレスはURLのパラメータに設定することで伝えることにします。

Thunderbird WebExtensions版アドオン

Thunderbirdではバージョン78より前はXULベースのアドオンを使用することができました。実のところ何年か前にXULベースのアドオンは作成していたのですが、バージョン78以降XULベースのアドオンへの対応が完全に廃止されたので、今回WebExtensionsベースで作り直したということになります。

// manifest.json
{
    "manifest_version": 2,
    "applications": {
        "gecko": {
            "id": "rmssearch@komari.jp",
            "strict_min_version": "78.0"
        }
    },
    "name": "RMS検索",
    "description": "メールアドレスをRMSで検索",
    "version": "0.5.1",
    "background": {
        "scripts": ["index.js"]
    },
    "permissions": [
        "messagesRead",
        "nativeMessaging"
    ],
    "message_display_action": {
        "default_title": "メールアドレスRMS検索"
    }
}
// index.js
function searchRMS(mailAddress) {
    const uri = "https://order-rp.rms.rakuten.co.jp/order-rb/search-order-sc/init?ordererMailAddress=" + encodeURIComponent(mailAddress);
    return messenger.runtime.sendNativeMessage('jp.komari.native_wrapper', { uri });
}

function extractMailAddress(displayName) {
    const [mail] = displayName.match(/(?:[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~.]+|"[ !#-~]*")@(?:[a-zA-Z0-9\-.]+|\[[0-9.]+\])/) || [];
    return mail;
}

function searchFromReplyTo(headers) {
    let addrs = [];
    for (const [header, values] of Object.entries(headers)) {
        if (header.toLowerCase().trim() != 'reply-to') {
            continue;
        }
        for (const value of values) {
            addrs = addrs.concat(value.split(','));
        }
    }
    return addrs;
}

function searchFromRaw(raw) {
    const [mail] = raw.match(/[a-zA-Z0-9]+@[a-z.]+\.rakuten.ne.jp/) || [];
    return mail;
}

function isRakutenMail(mailAddress) {
    return /@([a-zA-Z0-9\-.]+\.)?rakuten.ne.jp$/.test(mailAddress);
}

function isMailerDaemon(mailAddress) {
    return mailAddress == 'MAILER-DAEMON@rakuten.co.jp';
}

messenger.messageDisplayAction.onClicked.addListener(async tab => {
    const header = await messenger.messageDisplay.getDisplayedMessage(tab.id);

    let mail = extractMailAddress(header.author);
    if (!isRakutenMail(mail)) {
        const full = await messenger.messages.getFull(header.id);
        const replyToAddrs = searchFromReplyTo(full.headers);

        const addrs = header.recipients.concat(replyToAddrs);
        const recipientMail = addrs.map(extractMailAddress).find(isRakutenMail);

        if (recipientMail) {
            mail = recipientMail;
        }
    }
    if (isMailerDaemon(mail)) {
        const raw = await messenger.messages.getRaw(header.id);
        const bodyMail = searchFromRaw(raw);
        if (bodyMail) {
            mail = bodyMail;
        }
    }

    const result = await searchRMS(mail);
    console.log('Redirect RMS search', mail, result);
});

ファイルはmanifest.jsonindex.jsの2つだけです。これをzip形式で圧縮して拡張子をxpiにすればThunderbirdが認識できるアドオンになります。manifest.jsonは圧縮ファイルのルートに存在する必要があります。それ以外のファイルはmanifest.jsonからの相対パスを指定することでディレクトリに入れることもできます。

manifest.json

このファイルにはアドオンの情報を記述します。

applications.gecko.idはアドオンを識別する値になります。Native messagingによる別アプリケーションとの通信にも必要になります。

application.gecko.strict_min_versionにはこのアドオンが使用可能なThunderbirdの最低バージョンを記述します。WebExtensions自体はしばらく前から対応していたので78.0より小さくても動作するとは思いますが、とりあえず78を指定しておきます。

background.scriptsはアドオンの読み込み時に自動的に実行されるJavaScriptファイルを指定します。今回はindex.jsを指定しています。

permissionsには、このアドオンで使用するWebExtensionsの機能を記述します。アドオンのインストール時に「このアドオンはこれらの機能にアクセスします」といった感じでユーザは許可を求められます。その内容を吟味しているユーザはあまりいません。今回はメールを読み取ってメールアドレスを抽出する必要があるのでmessagesRead、別アプリケーションと通信する必要があるのでnativeMessagingを指定しています。

message_display_actionは、これを記述するとメール情報を表示しているところ(メール本文の上の領域)にボタンが追加されます。今回は指定していませんがdefault_popupプロパティを指定すると、ボタンに吹き出し状の領域が表示されて追加のメニューなど何らかのUIを表示することができます。

WebExtensions API

WebExtensionsはThunderbirdだけの機能ではなく、Chrome, Edge, Firefoxなどその他のブラウザの拡張機能でも使用可能なAPIです。各ブラウザで仕様の共通化が進められています。

ThunderbirdのWebExtensionsはグローバルのmessengerからアクセスできます。ブラウザ向けのドキュメントではbrowserと書かれていると思いますが、Thunderbirdではブラウザにはないメールソフト用の機能が追加されているのもあってmessengerを使用することがオススメされています。

WebExtensionsの各関数は基本的にPromiseを返します。

Native messaging

これはブラウザやメールソフトとは別のアプリケーションと通信する機能です。この別アプリケーションはOSのプロセスとして起動します。

messenger.runtime.sendNativeMessage関数で別アプリケーションにメッセージを送信します。第1引数にアプリケーション名、第2引数にペイロードを指定します。ペイロードはそのままJSON化して送信されるので、JSON化できる値ならオブジェクトでなくとも構いません。

アプリケーション名と実際に実行するプログラムを関連付けする方法はOSによって違いますが、Windowsの場合はレジストリにアプリケーション名のエントリを作成することで関連付けられます。

この関数は実行するたびに新しいプロセスを起動しますが、プロセスを常駐させるconnectNative関数もあります。

messageDisplay, messageDisplayAction

これはThunderbirdの3ペイン形式で表示されているタブのメッセージ表示領域(メールの内容が表示されているところ)を操作する機能です。

messenger.messageDisplayAction.onClicked.addListener関数は、manifest.jsonmessage_display_actionによって追加されたボタンにリスナを登録します。ボタンがクリックされると引数でタブやクリックの情報が渡されます。

messenger.messageDisplay.getDisplayedMessage関数は、指定したタブで表示しているメール(メッセージ)に関するデータが取得できます。これは表示しているメールそのもののデータではなく、メッセージ表示領域に実際に表示しているものに関するデータです。例えば、送信者名に日本語が含まれている場合、メールのヘッダではエンコードされたテキストになりますが、この関数で取得するデータはメッセージ表示領域に表示されている通りのデコードされているものになります。

今回は、送信者情報とメールIDを取得するために使用しています。

messages

これはThunderbirdで管理しているメールを操作する機能です。

messenger.messages.getFull関数はメールIDを指定することで、実際のメールデータを構造化した状態で取得できます。変な形式のメールでなければ、メールに含まれるほとんどの情報が含まれていると思います。

今回はreply-toヘッダの値を取得するために使用しています。

messenger.messages.getRaw関数はgetFull関数と同じですが、実際のメールデータを全て文字列のまま取得できます。

getFull関数では、配送エラーでMAILER-DAEMONから返ってきたメールの構造化ができていなかったので、エラーメールからもメールアドレスを取得するためにgetRaw関数を使用しています。

WebExtensionsはまだまだ成長する

ところで、Thunderbird 78.4以降ではmessageDisplayScriptsが実装されて、今まではできなかったメール表示領域でJavaScriptを実行することができるようになりました。

メール表示領域はDOMで管理されているので、ブラウザのようにDOMを操作することでメール本文の表示を操作することが可能になりました。これを利用すれば任意のURLへのハイパーリンクを作成することも可能です。メール表示領域のハイパーリンクをユーザがクリックすると、ThunderbirdはOSのデフォルトブラウザでそのURLを開きます。この方法ならNative messagingを使わなくても同じ機能が実現できるかもしれませんね。

XULベースのアドオンは廃止されてまだ日が浅いので、XULでできてWebExtensionsでできないことへの要求は日々高まっていくと思われます。しばらくは新機能の実装が続きそうです。

Thunderbirdに実装されているWebExtensionsについてはこちらを参照してください。

次回はNative messagingで呼び出されるアプリケーションについて

今回の記事で、Thunderbirdのアドオンは用意できました。しかしまだNative messagingで呼び出される側のアプリケーションが存在しません。

次回の記事では、そのアプリケーションの作成とレジストリへの登録などを紹介したいと思います。