アプリからGmailを送信する機能を実装しました!(MorimaTechWare)
Posted date at 2024-10-12Webアプリケーションから、ログインしているユーザでGmailを送信する機能を実装しました。メールの送信履歴はGmailに残ります。この機能のために、OAuth2.0認証とアクセストークンの管理を実装しましたので、その仕組みについても共有します。
制服アプリ(MorimaTechWare)では、以前紹介したReactPDF📒で注文書のPDFを生成し、独自のメール送信フォームからメールを送信できます。
🚀デモ動画
複数の注文データを選択して、メールを送信しています。メールの送信履歴が送信ボックスに残っていることを確認しています。
メール送信とは関係ないですが、PDFはプレビュー可能で、ローカルのPDFも複数選択して添付可能としています。またPDFは販売店毎に作成し、改ページは7件以上、発注担当者、納入先部署が変わる毎にしています。
🚀OAuth2.0認証
Gmailからメールを送るために、FirebaseAuthenticationとは別にOAuth2.0認証も実装しました。GmailAPIを使用するには、OAuth2.0で認証した際のレスポンスから取得するアクセストークンを指定する必要があります。なお、2つのログインを一括で行う実装は実装できなかったため、ユーザエクスペリエンスが下がってしまいました(FireBaseの認証をし、その後OAuth2.0認証してもらってログインが完了する)。
🚀トークンについて
私も含めて、トークンについて解像度が高くない方のために資料を用意しました。
トークンには2種類ある点が重要です。認証が成功すると、トークン(文字列)が認証サーバから渡されますので、これを利用して、リソースにアクセスします(今回はGmailのAPIを使用します)。
アクセストークンとリフレッシュトークンの違いを示します。今回はフロントエンドで認証をしていますので、現在使用しているアクセストークンを利用して、新しいアクセストークンを再発行しています。
🚀トークンの更新管理
OAuth2.0のトークンは1時間で無効になるため、トークンの有効時間を常に監視し、無効時間が近づいたら、トークンを再発行をする処理をしています。また、何らかの原因でトークンが取得できなかったときはログアウトするようにしています。
開発中のためコードが汚いですが、上記のロジックの実装部分を記載します。なんとなくイメージしてもらえたらと思います。
import { getIdToken, signOut } from "firebase/auth"; import { gapi } from "gapi-script"; import { useEffect, useState } from "react"; import { auth } from "../firebase"; import { API_KEY, CLIENT_ID, refreshTime, SCOPES, } from "../constants/googleAuth"; import { useDispatch, useSelector } from "react-redux"; import { push } from "connected-react-router"; import { signOutAction } from "../reducks/users/actions"; import { signInWithTokenAction } from "../reducks/token/actions"; import { getSignedIn } from "../reducks/users/selectors"; import { getAccessToken } from "../reducks/token/selectors"; import { useMobile } from "./useMobile"; import { formatDate, formatTime } from "../functions/utils/formatting"; export const useAccressToken = () => { const [token, setToken] = useState(""); const isSignedIn = useSelector(getSignedIn); const { isMobile } = useMobile(); // const accessToken = useSelector(getAccessToken); // useEffect(() => { // console.log("token", accessToken); // }, [accessToken]); // const accessTokenFromSelector = useSelector(getAccessToken); // const selector = useSelector((state) => state); const dispatch = useDispatch(); useEffect(() => { if (isMobile) { console.log("モバイル環境のため、トークンの監視・更新を無効化"); return; } const initClient = async () => { await gapi.client.init({ apiKey: API_KEY, clientId: CLIENT_ID, scope: SCOPES, discoveryDocs: [ "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", ], }); const authInstance = gapi.auth2.getAuthInstance(); authInstance.isSignedIn.listen((signedIn) => { if (signedIn) { handleUserSignIn(); // サインイン時の処理 } else { handleUserSignOut(); // サインアウト時の処理 } }); const signedIn = authInstance.isSignedIn.get(); if (signedIn) { await handleUserSignIn(); } else { console.log(isSignedIn); } }; const handleUserSignIn = async () => { const authInstance = gapi.auth2.getAuthInstance(); const user = authInstance.currentUser.get(); const newAuthResponse = await user.reloadAuthResponse(); // アクセストークンをリフレッシュ const accessToken = newAuthResponse.access_token; const authResponse = user.getAuthResponse(true); const currentTime = Date.now(); const expiresAt = authResponse.expires_at; const expiresIn = expiresAt - currentTime; console.log( `ロード:アクセストークンの残り有効時間: ${ expiresIn / 1000 / 60 }分:${formatTime(new Date())}` ); setToken(accessToken); dispatch(signInWithTokenAction({ accessToken: accessToken })); }; const handleUserSignOut = () => { signOut(auth).then(() => { console.log("signOut3"); dispatch(signOutAction()); dispatch(signInWithTokenAction()); gapi.auth2.getAuthInstance().signOut(); dispatch(push("/signin")); }); }; gapi.load("client:auth2", initClient); }, []); const refreshAccessToken = async () => { if (isMobile) { console.log("モバイル環境のため、トークンの監視・更新を無効化"); return; } try { const authInstance = gapi.auth2.getAuthInstance(); const currentUser = authInstance.currentUser.get(); console.log(`現在のユーザー:${currentUser.isSignedIn()}`); // ユーザーがサインインしているか確認 if (authInstance.isSignedIn.get()) { const authResponse = currentUser.getAuthResponse(true); const expiresAt = authResponse.expires_at; const currentTime = Date.now(); const expiresIn = expiresAt - currentTime; const minutesLeft = expiresIn / 1000 / 60; console.log( `定期確認:アクセストークンの残り有効時間: ${minutesLeft.toFixed(2)}分` ); // 残り時間が5分以下の場合にトークンを更新 if (minutesLeft <= LIMIT_TIME) { const newAuthResponse = await currentUser.reloadAuthResponse(); setToken(newAuthResponse.access_token); dispatch( signInWithTokenAction({ accessToken: newAuthResponse.access_token }) ); console.log( `更新しました!:アクセストークンの残り有効時間: ${minutesLeft.toFixed( 2 )}分:トークン${newAuthResponse.access_token}` ); } } else { signOut(auth).then(() => { console.log("signOut1"); dispatch(signOutAction()); dispatch(signInWithTokenAction()); gapi.auth2.getAuthInstance().signOut(); dispatch(push("/signin")); }); } } catch (error) { console.error("Error refreshing access token:", error); // alert(`トークンのリフレッシュに失敗しました。`); signOut(auth).then(() => { console.log("signOut2"); dispatch(signOutAction()); dispatch(signInWithTokenAction()); gapi.auth2.getAuthInstance().signOut(); dispatch(push("/signin")); }); } }; useEffect(() => { if (isMobile) { console.log("モバイル環境のため、トークンの監視・更新を無効化"); return; } let refreshInterval; refreshInterval = setInterval(() => { refreshAccessToken(); }, refreshTime); return () => { if (refreshInterval) clearInterval(refreshInterval); }; }, []); return { token, setToken }; };
コンソールデバッグ時の画面を共有します。トークンの有効期限が残り5分で更新されているのがわかると思います。
🚀まとめ
アプリからGmailを送信する方法を共有しました。GASやkintone、SaaS製品であればもっと簡単に実装できるかもしれませんが、ゼロベースのオリジナルアプリでの実装としては良いプラクティスなのではないかと思います。
なお、添付文書無しであれば、URLのクエリでタイトルや本文を指定して、メールの新規作成画面を表示できるようです。
←ホームに戻る