Counts the Clouds

Firebase Functionsでメールフォーム
2021.06.09

Firebase
Firebase Functions
Firebase Hosting
Google reCAPTCHA
Gatsby
nodemailer

Firebase Functionsに、nodemailerでメールを送信する関数をデプロイしてメールフォームとして使う。スパム対策にreCAPTCHAを使う。

nodemailer

まず、nodemailerのExampleを手元で動作確認した。

Firebase Functions + nodemailer

firebase functionsの設定をして、nodemailerのExampleを移植した。メールサーバーは普段使っている国内のホスティングサービスのものをとりあえず当てた。

'use strict';

const functions = require('firebase-functions');
const nodemailer = require('nodemailer');

exports.emailMessage = functions.https.onRequest(async (req, res) => {
  async function main() {
    let transporter = nodemailer.createTransport({
      host: '<ホスト>',
      port: 587,
      secure: false, // true for 465, false for other ports
      auth: {
        user: '<メールユーザー>',
        pass: '<メールパスワード>',
      },
    });

    let info = await transporter.sendMail({
      from: '<送信元>', // sender address
      to: '<宛先>', // list of receivers
      subject: 'Hello ✔', // Subject line
      text: 'Hello world!', // plain text body
      html: '<b>Hello world!</b>', // html body
    });

    console.log('Message sent: %s', info.messageId);

    res.status(200).send(info.messageId);
  }
  main().catch(console.error);
});

ローカルでfunctionsを起動。ファイルの変更があるとhot reloadされる。

% firebase serve

メーラーの秘匿情報をfunctionsの環境変数に移動する。

% firebase functions:config:set sender.user=<メールユー> \
sender.password=<メールパスワード>

エミュレータからはfunctionsの環境変数に直接アクセスできないのでファイルに書き出す。functionsディレクトリで、

% firebase functions:config:get > .runtimeconfig.json

宛先をリクエストに含めてJSONでPOSTする例。

% curl -X POST \
-H "Content-Type: application/json" \
-d '{"email":"hoge@example.com"}' \
<functionのURL>

ここまでのソースコード。

'use strict';

const functions = require('firebase-functions');
const nodemailer = require('nodemailer');
const { sender } = functions.config();

exports.emailMessage = functions.https.onRequest(async (req, res) => {
  async function main() {
    let transporter = nodemailer.createTransport({
      host: sender.host,
      port: Number(sender.port),
      secure: sender.secure.toLowerCase() === 'true', // true for 465, false for other ports
      auth: {
        user: sender.user,
        pass: sender.password,
      },
    });

    let info = await transporter.sendMail({
      from: sender.user, // sender address
      to: req.body.email, // list of receivers
      subject: 'Hello ✔', // Subject line
      text: 'Hello world!', // plain text body
      html: '<b>Hello world!</b>', // html body
    });

    console.log('Message sent: %s', info.messageId);

    res.status(200).send(info.messageId);
  }
  main().catch(console.error);
});

reCAPTCHA v3

スパム対策は必須なのでreCAPTCHAを導入したい。まず、フロントエンドから検証できるようにするため、同じfirebaseプロジェクトにhostingを追加した。

% firebase init hosting

? What do you want to use as your public directory? public
? Configure as a single-page app (rewrite all urls to /index.html)? Yes
? Set up automatic builds and deploys with GitHub? No

公式の手順でGatsbyを初期化。検証用にしては面倒だが、最終的にGatsbyから動かしたいのでまあよしとする。

% gatsby build
% firebase emulators:start --only hosting

で表示されるか確認。errorがうっとうしいので404ページを追加した。確認できたのでhostingだけdeployした。

% firebase deploy --only hosting

.cachepublicディレクトリを.gitignoreに追加した。

hostingからfunctionsを呼べるようにする

firebase.jsonのrewritesに追加。SPA設定より優先されるように上に追記する。

{
  "source": "/func/contact",
  "function": "emailMessage"
},

reCAPTCHAのコンソールでサイトの設定を行う

特に難しいことはない。

reCAPTCHA v3ではlocalhostと本番環境で登録サイトを分けておくのがいいとのことなのでそのようにした。開発環境がhttp://localhost:xxxxなら、「ドメイン」は単にlocalhostでよい。開発用のサイトは1つあれば十分な気がする。

サイトキーは公開環境に置く前提とはいえ、ソース管理に含めたくなかったので.envファイルで指定できるようにした。

gatsby-config.jsにdotenvの設定を追加。dotenvはGatsbyの依存に含まれている。

require("dotenv").config({
  path: `.env.${process.env.NODE_ENV}`,
})

これで、開発中なら.env.development、ビルドタイムなら.env.productionを読み込む。Gatsbyで使うキーはGATSBY_プレフィックスをつけておくと自動的にprocess.envにセットしてくれる。

GATSBY_GOOGLE_RECAPTCHA_SITE_KEY="<reCAPTCHAのコンソールで表示されるサイトキー>"

フロントエンドでreCAPTCHAのトークンを取得

検証用のフロントエンドをGatsbyにしてしまったのでreCAPTCHAのトークンを取得するのに少し難儀した。

最終的にreact-google-recaptcha-v3を使用して実装することにした。

import React from 'react'
import {
  GoogleReCaptchaProvider,
  GoogleReCaptcha
} from 'react-google-recaptcha-v3';
export default function Home() {
  const handleVerify = (response) => {
    console.log(response)
    // あとでPOSTを実装
  }
  return (
    <div>
      <h1>Hello Gatsby!</h1>
      <GoogleReCaptchaProvider reCaptchaKey={process.env.GATSBY_GOOGLE_RECAPTCHA_SITE_KEY}>
        <GoogleReCaptcha onVerify={handleVerify} />
      </GoogleReCaptchaProvider>
    </div>
  )
}

これで、トークンが返ってくることを確認した。

functionsでreCAPTCHAのトークン検証

fetchがないのでnode-fetchを追加した。

% npm install node-fetch

秘匿情報にreCAPTCHAのシークレットキーを追加。開発環境と本番環境でシークレットキーを出し分けたいので、それぞれをconfigに追加した。

% firebase functions:config:set recaptcha:secrets:development=xxx \
recaptcha:secrets:production=xxx

firebaseにはNODE_ENVがないのでFUNCTIONS_EMULATOR === trueでエミュレータだったら開発環境、とした。

リクエストに含まれるトークンを検証する関数。

async function challengeToken(token) {
  const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
    method: 'post',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
    },
    body: `secret=${getReCaptchaSecretKey()}&response=${token}`,
  }).catch((ex) => {
    console.warn(ex);
    return { success: false };
  });
  const json = await res.json();
  return json;
}

簡単なテストのため、フロントエンドに戻ってトークンをtextareaに表示できるようにした。そのトークンをコピーして、

% curl -X POST \
-H "Content-Type: application/json" \
-d '{"email":"hoge@example.com","token":"<トークン>"}' \
http://localhost:5000/func/contact

コマンドに貼り付けて叩くと、functions側のチャレンジが成功した。

{
  success: true,
  challenge_ts: '2021-05-24T09:26:57Z',
  hostname: 'localhost',
  score: 0.9
}

reCAPTCHA v3だと人間がbot判定されたときどうなるんだろう。検証なのでこのままv3ですすめるが、メールフォームに使う場合はクイズで救出できるv2のほうがいいのかもしれない。

フロントエンドにUIを追加

コピペでは実際には使えないので、ボタンとダミーの入力欄を追加して、送信時にトークンを取得して一緒にfunctionsに送るようにした。

import React, { useState } from 'react'
import {
  GoogleReCaptchaProvider,
  useGoogleReCaptcha,
} from 'react-google-recaptcha-v3'

function ReCaptchaForm() {
  const [email, setEmail] = useState('')
  const { executeRecaptcha } = useGoogleReCaptcha()
  const onSubmit = async () => {
    if (!executeRecaptcha) {
      console.log('Execute recaptcha not yet available')
      return
    }
    const token = await executeRecaptcha('firebaseNodemailerTrial')
    await fetch('http://localhost:5000/func/contact', {
      method: 'POST',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        email: 'email@exaple.com',
        token: token,
      }),
    })
  }

  const onChange = (e) => {
    setEmail(e.target.value)
  }

  return (
    <form>
      <input type="text" defaultValue={email} onChange={onChange} required />
      <button onClick={onSubmit}>Verify reCAPTCHA</button>
    </form>
  );
}

export default function Home() {
  return (
    <div>
      <h1>Hello Gatsby!</h1>
      <GoogleReCaptchaProvider reCaptchaKey={process.env.GATSBY_GOOGLE_RECAPTCHA_SITE_KEY}>
        <ReCaptchaForm />
      </GoogleReCaptchaProvider>
    </div>
  )
}

この時点でデプロイ。firebase deployだとfirebase Error: HTTP Error: 403, Unknown Errorが出たがfirebase deoploy --only functionsとするとデプロイできた。functionsのデプロイが初回だったからかもしれない。

本番環境でメールを送信できない

メールサーバーで国外IPアドレスがフィルタリングされているようなので、functionsのリージョンを国内にしてCORSを動作するようにしてみたが、hostingからrewritesしたfunctionsをonRequestで呼び出すにはus-central1リージョンでないとだめとのこと。前にも同じハマりかたをした気がする。

設定が少し面倒なので避けていたが、SSLで送信できるし、結局は早そうだということで考えなおし、Gmailを使うことにした。

nodemailer + Gmailでメール送信

Gmailでメールを送信するにはメールアドレスのほかに、OAuthクライアントのClient ID、Client Secret、Refresh Tokenが必要。firebaseプロジェクトとひもづくGoogle Cloud Platformのコンソールにアクセス。手順は複雑なので大雑把に示す。

  1. OAuth 同意画面を作成して公開
  2. 認証情報を作成してClient IDとClient Secretを取得
  3. Google DevelopersのOAuth 2.0 PlaygroundでRefresh tokenを取得

こちらが参考になった。

準備できたのでnodemailerでgmailに送信できるようにする。configに秘匿情報を追加。

% firebase functions:config:set gmail.user=<Gmailのメールアドレ> \
gmail.clientsecret=<Client ID> \
gmail.clientid=<Client Secret> \
gmail.refreshtoken=<Refresh Token>

function全体ではこのようになった。

'use strict';

const cors = require('cors')({ origin: true });
const fetch = require('node-fetch');
const functions = require('firebase-functions');
const nodemailer = require('nodemailer');
const { gmail, sender, recaptcha } = functions.config();

function getReCaptchaSecretKey() {
  if (process.env.FUNCTIONS_EMULATOR) {
    return recaptcha.secrets.development;
  }
  return recaptcha.secrets.production;
}

async function challengeToken(token) {
  const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
    method: 'post',
    mode: 'no-cors',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
    },
    body: `secret=${getReCaptchaSecretKey()}&response=${token}`,
  }).catch((ex) => {
    console.warn(ex);
    return { success: false };
  });
  const json = await res.json();
  return json;
}

async function sendGmail(fromEmail) {
  const transporter = nodemailer.createTransport({
    service: 'gmail',
    auth: {
      type: 'OAuth2',
      user: gmail.user,
      clientId: gmail.clientid,
      clientSecret: gmail.clientsecret,
      refreshToken: gmail.refreshtoken,
    },
  });
  
  const mailOptions = {
    from: fromEmail,
    to: gmail.user,
    subject: 'Hello ✔', // Subject line
    text: 'Hello world!', // plain text body
    html: '<b>Hello world!</b>', // html body
  };
  
  const info = await transporter.sendMail(mailOptions);

  console.log('Message sent: %s', info.messageId);

  return info;
}

async function main(req, res) {
  const result = await challengeToken(req.body.token);
  if (!result.success) {
    res.status(403).send(result);
    throw new Error('Challenge failed');
  }
  if (result.score < 0.5) {
    res.status(403).send(result);
    throw new Error('Score is too low');
  }

  const info = await sendGmail(req.body.email).catch((ex) => {
    console.error(ex);
    res.status(403).send(ex);
  });

  res.status(200).send(info.messageId);
}

exports.emailMessage = functions.https.onRequest((req, res) => {
  if (process.env.FUNCTIONS_EMULATOR) {
    return cors(req, res, () => main(req, res));
  }
  main(req, res);
});

reCAPTCHAでドメインを除外するのを前提とすると、rewritesの設定で同一ドメインにする必要もなく、onRequest内でCORSを返してしまってもよさそう。

結局、このブログはGatsby Cloud経由でFirebase hostingを使用しているので、functionは絶対パスで呼び出している。

簡単なメールフォームなら、サーバレスで手作りできることがわかった。