📝

React使ったChrome拡張開発がいい感じにできるようになっている

2022/12/12に公開

会社でGmailを送信する前にオリジナルのチェック項目を出すというChrome拡張を作っていて、この1年半くらいで何回かアップデートをしました。Chrome拡張開発がいい感じにできるようになっていたので紹介します。

業務課題とそれに対して作ったもの

メールの誤送信

メールに関する事故ランキングがどういう順番になっているかは知りませんが、誤送信はおそらく相当上位に入るでしょう。送る人を間違えた、送る人は合ってるけど添付ファイルが違う顧客用のものだった、送る人も間違えてるしファイルも間違えてるし本文の名前も間違えてた、浮気相手へのメールを嫁に送ってしまった、などありとあらゆるパターンで誤送信は起こっているはずです。
考えてみたことがある人もいると思いますが、メールの内容として何が正解なのかはほとんどの場合自分しか知らず、機械的な値検証をするのが非常に難しいです。これから起こる誤送信を現実的なコストで事前に検知する方法はおそらく送る前にもう一回確認するしかないでしょう。この確認を自分がやるのか、他の人にもやってもらうのか、さらには確認して承認がないと送れないような仕組みにするのか、どのぐらいメールを送ることに恐怖を感じて何段階確認の過程を入れるかしか違いはありません。
そして誤送信は起こってしまってから対応するのが難しいという特徴もあります。送ってしまえば仮に1秒後に気づいてももう遅いということがほとんどで、やってしまった後にやることはごめんなさいもうしませんと宣言するばかりになってしまいます。余計な仕事を増やさないためには事前に検知するしかありません。

既存の誤送信対策

(Gmailを前提としてお話をします)
私が調べた限りは、この事前検知をする仕組みとしては以下のようなものがありました。

1つ目の事前承認というのは他の人にも送る前に見てもらおうというものになるわけですが、コストが非常にかかります。

  • 数が多くなると承認する人がめちゃくちゃ大変
  • 承認待ちが発生する
  • 承認する人が正解を理解していないといけない

今日中に送らないといけないのに承認する人が忙しくて全然見てくれない、忙しいから承認する側も適当になるなど、すべてのメールに適用しようとするとおそらく効果が出にくくなります。もっと言うと事前承認の仕組みを作るのがめちゃくちゃ大変なのでやるとすればなんらかのサービスを契約するしかなく、お金の面でもコストがかかります。

事前検知はやはり送る本人が見るのが一番精度が高くなります。そういう意味で2つ目のChrome拡張は本人による確認を補助してくれるものになります。しかし、一般に公開されているものを使うとチェック項目が限定的になります。(限定的というか普通見るべき内容)

  • 宛先(To,Cc,Bcc)
  • 件名
  • 添付ファイル名

会社によってはメールの使い方に特徴があり、その特徴もカバーしたチェックをすることでより誤送信のリスクが減ります。

新しく作ったChrome拡張

我が社(以下、A社)専用でGmail送信前に事前に確認項目を出すChrome拡張を作りました。動作しているところをGIFで上げたりしたいのですが内容的にちょっと難しいのでチェック画面の一部になりますが、こんな見た目になっています。完全にBootstrap丸出しですが、社内用アプリであればこれで十分でしょう。(と強がっていますが本当はデザイン考えてくれよ誰かと思っています。)

(↑この間のチェック項目はA社独自のもの↓)

下書きを開いている状態で拡張のアイコンをクリックするとメールを取得するボタンが出てきて、ボタンを押すと下書きに応じたフォームが出てきます。確認項目がチェックボックスになっていて、すべてチェックしないとメールが送信できないという仕組みです。そのメールについてそもそも確認が必要ない項目については最初からチェックをつけるようにしました。(画像の場合はBccに誰も指定していないので起動直後にチェックされている)ちなみに下書き画面側の送信ボタンもcontent-scriptを介して無効化していて、拡張を起動しないとメールが送れないようにしています。

チェック項目ですが、↑に挙げた公開されているChrome拡張でカバーしている項目に合わせて、以下のような内容も出すようにしています。

  • 本文の最初の3行
    • 画像はそもそもサンプルを2行しか書かなかったので2行分だけ出ています
  • 本文に含まれるURLの候補
  • 宛先に顧客リストとドメインを照合した結果を追加
    • 画像のexample.comは顧客リストにないので該当なしと出ています
    • ヒットした場合は顧客名がでます
  • 件名、本文、添付ファイル名に含まれる顧客名の候補

まず本文の最初の方というのはだいたい

xx株式会社 yy様

お世話になっております。A社のzzです。

的などうでもいい内容が入ってきます。メールをコピペしてきた場合など、ここが違う人になっていることもあるので確認できます。URLもほとんどがどこかからコピペされてくるものになるので、拡張機能で再度確認できるようにしています。
次に宛先というのは意外としっかり見ないと確認が漏れる範囲で、メールアドレスだと似た形式の顧客がいて見間違わないとも限りません。事前に定義されているということは前提になりますが顧客リストと一致した場合は顧客名も一緒に出すようにしています。
さらに本文や添付ファイルからも顧客名を抜き出すようにしています。ローカルにあるファイルのうち送りたい顧客用のものでないものが添付されていたら、顧客名のリストで気づきやすくなるという感じです。
追加でつけたチェック項目は主に事前に定義された顧客リストに依存するようになっていて、それがメンテナンスされていないとチェックが効きませんが、すでにやり取りの履歴がある既存顧客とのメールというのは頻度が高いので、顧客リストを更新するという少しの工数でChrome拡張が提案してくる内容の精度が高まり、毎回の確認コストが下がるという仕組みになっています。

仕組み

(Chrome拡張自体の仕組みの説明は省略します)
こんな説明をするくらいならコードを載せたいのですが、今のところ載せられないので仕組みを書いておきます。(いつかオープンソース版を作りたい)

  • Popupからメール取得ボタンを押す
  • content-scriptにメールの下書きIDを問い合わせる
    • DOMからGmail APIへのリクエスト時に必要な下書きIDが取れます
    • (取れなくなったらこの拡張がいろいろ死にます)
  • Gmail APIから下書きの内容をもらう
    • chrome.identity.getProfileUserInfo()でChromeにログインしているユーザーのアドレス
    • chrome.identity.getAuthToken()でGmail APIへリクエストするためのBearerトークン
    • トークンを使ってPopup内からGmail APIを呼んで、下書きIDとメールアドレスで下書きの内容がもらえます
  • content-scriptからも下書きの内容をもらう
    • APIで返ってくる本文はHTML形式になっていて先頭3行を取るのがめんどうなので、ページから取得しています
    • 添付ファイルのURLは下書きの状態ではページにしかない(GmailAPIから返ってこない)のでページからもらっています
  • GmailAPIから取れるメールの内容とcontent-scriptから取れるメールの内容をそれぞれ別のstateとして持っておき、取得が完了して顧客リストとの照合が完了した時点でsetState()する
import { useState } from 'react'

type APIからのメール内容 = {
  subject: string,
  to: string[],
  cc: string[],
  bcc: string[],
  snippet: string,
}

const Popup = () => {
  const [APIからのメール内容, setAPIからのメール内容] = useState<APIからのメール内容>({
    subject: '',
    to: [],
    cc: [],
    bcc: [],
    snippet: ''
})
  • 取得した内容をPopupに表示
    • 取得中はよくあるisLoadingでフォーム自体を隠しています
  • 送信ボタンが押されたら、実際のGmailの下書き画面側の送信ボタンをクリックする
    • react-hook-formですべてのチェック項目をrequiredにしているので、未チェックがある場合は勝手にそこまで画面がスクロールしてくれます
    • 送信するとGmailの下書き画面もPopupも両方閉じます

Chrome拡張開発するときの技術選定

この拡張機能を作るに当たって、何回か技術選定をやり直しました。

HTML + VanillaJS
-> Google workspaceアドオン(Chrome拡張を一瞬やめた)
-> HTML + VanillaJS
-> create-react-appで作ったReact環境
-> React + Typescript + vite + crxjs vite plugin

HTML + VanillaJS

悪くはありませんが、現代的なフロントエンド開発ができない、型がないといったところでやりにくいと感じることが多いように思います。Reactの環境構築する工数すら無駄というくらいの簡単な機能であれば採用してもいいかもしれません。

Google workspaceアドオン

Google workspaceアドオン形式にすると、Gmail、Googleドライブ、Googleスプレッドシートなどの画面を開いているときに右側に独自画面と機能をつけることができます。しかもGoogle apps scriptだけで書けるので非常に管理がやりやすいです。
Gmail以外にもNotionの記事を検索するアドオンを作ってみたりしましたが、GASのライブラリが使えるので開発工数が削減になったり、使える場面は多いように感じました。
ただGmailの下書きボタンをクリックするなどDOMにはアクセスできないので、その場合はChrome拡張での開発が必要になります。

webアプリ用のReact

いわゆる普通のReact環境をcreate-react-appで作ってそこでやってみるというのもしましたが、content-scriptの扱いが難しくあきらめました。詳細は下に書いています。

React + Typescript + vite + crxjs vite plugin

長々と書いてきましたがこの記事で一番言いたいところです。悩みがほぼすべて解決しました。直近のアップデートでこの構成にしたのですが、とにかく開発効率の向上を感じたのでその部分について書いていきます。一番重要なのはcrxjsです。

環境構築が簡単

crxjsに書いてある通りに進めると一瞬で終わります。

npm init vite@latest

でTypescriptなどオプションを選んでプロジェクトを作った後に、

npm i @crxjs/vite-plugin@latest -D

でcrxjsを導入し、configを書くだけです。create-react-appでも同様にコマンド1つでReact環境は作れますが、実は出来上がるのはChrome拡張開発があまり簡単にならない環境です。
configを載せておきます。

vite.config.ts
import * as dotenv from 'dotenv'
dotenv.config()
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { crx, defineManifest } from '@crxjs/vite-plugin'

const manifest = defineManifest({
  manifest_version: 3,
  name: "Gmail Check Extension",
  version: "0.1.0",
  key: process.env.EXTENSION_KEY,
  oauth2: {
    client_id: process.env.GOOGLE_CLIENT_ID,
    scopes: [
      "https://mail.google.com/",
      ...
    ]
  },
  icons: {
    "16": "src/assets/icon16.png",
    ...
  },
  permissions: [
    "identity",
    "identity.email"
  ],
  action: {
    default_icon: {
      "16": "src/assets/icon16.png",
      ...
    },
    default_popup: "index.html"
  },
  content_scripts: [
    {
      matches: ["https://mail.google.com/*"],
      js: ["src/content.tsx"]
    }
  ]
})

export default defineConfig({
  plugins: [
    react(),
    crx({manifest})
  ]
})

manifestファイルがTypescriptで扱えるようになった

なんとviteのconfigにmanifestが含められます。Chrome拡張にはmanifest.jsonが必要ですが、jsonとして管理する必要がなくなりました。
今回はGmailのAPIを各ユーザー権限で叩くという仕組みが入っているためmanifestにはGoogleのクライアントIDなどを含める必要があったのですが、実際の値は.envにしておいて、ビルド時に値を入れてくれるという作りになったおかげで非常に管理がしやすくなりました。同時にクレデンシャルが入るせいでmanifest.jsonをバージョン管理に含められなかったという問題が解決しました。他にも配列の最後のカンマを入れられるとか、コメントを入れられるとか、lintを適用できるとかいいことばかりです。

content-scriptがReactアプリで扱えるようになった

HTML + VanillaJSはさすがにそろそろやめようと思って、じゃあReactにしようと何も考えずにcreate-react-appでReactアプリを作ってコードの移行を始めたときがありました。popup.htmlをPopup.tsxにするのはそんなに困りませんが、conten-script.jsを移そうとして気づきます。

「これどうやってビルドすんの?」

Popupとcontent-scriptを使うChrome拡張の場合、Chromeの各タブで動くcontent-scriptがサーバー、Popupがフロントエンドのような関係になるので、同じReactで書けるとしてもそれぞれは別々の環境で動くものとしてビルドされなければいけません。(App.tsxにcontent-scriptは含まれない)

いろいろと調べまくりましたが、私としてはcreate-react-appというかwebアプリのためのReactでcontent-scriptも扱うChrome拡張を作るのは無理という結論に至りました。(Popupだけならいけるはずです)
私は試していませんが、仮にやるとすれば以下のような方法になるようです。

  • Popup側とは別にcontent-scriptもビルドできるwebpack設定を自分で書く
    • create-react-appなら一旦ejectすることになります
  • (実質↑と同じかもですが)Popupとcontent-scriptを完全に別のアプリとして構成する
    • それぞれのビルド結果が互いをどのように解決するかは自分でなんとかすることになります

私はフロンドエンドエンジニアではなく、webpackをきっちり理解しているわけでもなく、実現したいこととがんばった結果得られるものの差分を考えると挑戦しようとする気すら起こりませんでした。

これがcrxjsだとどうなるかというと、まずファイルの構成はこんな感じになっています。

src
- main.tsx <- ReactDOMのrenderを呼んでいる本体
- Popup.tsx <- main.tsxでimportされるPopup
- content-tsx <- content-script
- vite-end.d.ts
- assets
  - xxx.json
  - yyy.png
  - zzz.png
- components
  - xxx.tsx

上のvite.config.tsを見てもらいたいんですが、content-scriptに当たるtsxファイルを指定しています。

  content_scripts: [
    {
      matches: ["https://mail.google.com/*"],
      js: ["src/content.tsx"]
    }
  ]

それ以外には何の設定もなく、Popupとcontent-scriptが同じReactアプリの中で共存できるようになりました。ちなみにビルドした結果のdist配下はこんな感じになっていて、勝手にcontent-scriptに対応してそうなファイルを作ってくれています。

dist
- index.html
- manifest.json
- service-worker-loader.js
- assets
  - index.html.acd41235.js
- src
  - assets
  - content.tsx.js
  - content.tsx-loader.js
- vendor

今回はbackground-scriptは最終的に使わなかったのですが、同様にmanifestに指定するだけで使えます。WebアプリとしてReactを使おうとすると大工事が必要になる部分が一瞬で解決しました。

content-scriptのrenderの書き方

今回のcontent-scriptはDOMにはアクセスしますが変更を加えないものなので、こういう書き方になりました。ドキュメントとは少し違います。

import React from 'react'
import ReactDOM from 'react-dom/client'

// いろいろ

const root = document.createElement('div')
root.id = 'crx-root'
document.body.append(root)

ReactDOM.createRoot(document.getElementById('crx-root') as HTMLElement).render(
  <React.StrictMode>
  </React.StrictMode>
)

コードの変更がChromeに自動で反映されるようになった

Chrome拡張はデベロッパーモードをONにすることで、manifest.jsonとその他必要なコードやファイルを含んだローカルのフォルダを指定して拡張機能を動かすことができます。ただ、コードの変更と同時にビルド結果がdistフォルダに反映されるような環境を作っていたとしても、Chromeの拡張機能管理画面で毎回更新ボタンを押さなければ変更は反映されません。HTML + VanillaJSのような構成であれば当然です。
これがcrxjs(とReactなどのcrxjsが動かせるフロンドエンド環境)になると、yarn run devしておけばmanifestの更新もコードの更新も勝手にChromeに適用され、ブラウザをリロードしてくれます。いちいち拡張機能管理画面に行く手順から解放されるのはこの構成にしたことによって得られた最大の効果と言ってもいいかもしれません。

その他

Typescript化の意味は語るまでもありませんが、@types/chromeによってchrome APIの型情報が使えるようになりました。(ちなみに一部のAPIはドキュメントだとPromiseに対応していますが、@types/chromeではまだ反映されていないものもあります。)
またReact化によって、VanillaJSのときよりも個人に依存した書き方が少なくなり、メンテナンス性が向上しました。

まとめ

Chrome拡張を作った背景と本題が半々くらいになってしまいましたが、crxjsによってかなりChrome拡張開発がやりやすくなったと感じます。今回はReactでやりましたがvueでもなんでもcrxjsが対応さえしてくれていれば何を選んでもいいというのも自由度があっていいと思います。業務のアプリケーション化を考えるときにChrome拡張まで選択肢に入れるというのは工数を考えるとけっこうハードルが高い気がしますが、今回開発してみてもっと気軽にいろんな場面でやってみてもいいように思いました。
またGoogle workspaceを導入している会社の場合、業務アプリをChrome拡張やGoogle workspaceアドオンとして開発し、ChromeウェブストアやGoogle workspaceマーケットプレイスへ組織内限定で公開することによって、管理コンソールからの配布や特定のグループに対して強制インストールなども簡単になります。
Chrome拡張開発はweb上の情報が限られているように感じたので、この記事を見てまた開発する人が増えて、どんどん情報が増えていくといいなと思います。

Discussion