Firebase Functionsでメールフォーム 2021.06.09
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
.cache
、public
ディレクトリを.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のトークンを取得するのに少し難儀した。
- reCAPTCHAチュートリアルのコードをreact-helmetで実装→失敗
- gatsby-recaptcha-plugin(react-recaptcha)→reCAPTCHA v2なので使えなかった
最終的に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のコンソールにアクセス。手順は複雑なので大雑把に示す。
- OAuth 同意画面を作成して公開
- 認証情報を作成してClient IDとClient Secretを取得
- Google DevelopersのOAuth 2.0 PlaygroundでRefresh tokenを取得
こちらが参考になった。
- https://hongo.dev/blog/nodemailer-send-emails-using-alias-address-for-gmail
- https://zenn.dev/hisho/scraps/efbcb7cd2f7b82
- https://gist.github.com/neguse11/bc09d86e7acbd6442cd4
準備できたので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は絶対パスで呼び出している。
簡単なメールフォームなら、サーバレスで手作りできることがわかった。