「既存の問い合わせフォームは届くのに、新しく足した通知メールだけ届かない」「返ってくるのは {"success":false,"message":"エラーが発生しました"} の一言だけ」「ログを見ようとしたら clasp tail-logs に拒否された」——Google Apps Script(GAS)で動いているウェブアプリに機能を足したときの、地味に長引く詰まりです。
追加したコードは何度読んでも正しい。既存の経路は今も動いている。それでも新しい経路だけが落ちる。この形になったら、疑うのはロジックではなく OAuthスコープ です。GASにはメールを送るAPIが2つあり、要求する権限が違うからです。
この記事では、症状の見分け方、エラーの実文を取り出す手順、直し方、そしてリリース順序の罠までを順に扱います。
- GASのメール送信APIは
MailApp と GmailApp の2つあり、要求するOAuthスコープが違う -
GmailApp で承認済みのプロジェクトに MailApp を足すと、追加した経路だけが未承認で落ちる -
clasp tail-logs はGCPプロジェクト未設定だと使えない。catch を一時的に error.stack を返す版へ差し替えて curl で叩けば、必要な権限名と落ちた行番号まで一発で出る - 直し方は「既存の呼び方に揃える」。書く前に
grep を1回打てば防げた話 - フロントを先に出すとサーバ側が新しい入力を知らず、利用者には成功に見えたまま何も残らない。GASを先に更新する
目次を開く
症状: 追加した経路だけが失敗する
典型は、doPost の中で入力の種類ごとに処理を分けているウェブアプリです。
function doPost(e) {
const params = JSON.parse(e.postData.contents);
if (params.formType === 'template') {
return sendTemplateNotificationEmail(params); // 新しく足した経路 → 落ちる
}
return sendContactEmail(params); // 既存の経路 → 正常
} 新しい経路だけが、次を返します。
{"success":false,"message":"エラーが発生しました"} catch が例外を握り潰して定型の文言に置き換えているため、返ってくる文字列から原因が一切分かりません。ここが厄介なところです。
原因: メール送信APIが2つあり、要求する権限が違う
| 呼び方 | 要求するOAuthスコープ |
|---|---|
GmailApp.sendEmail(...) | .../auth/gmail.send |
MailApp.sendEmail(...) | .../auth/script.send_mail |
(先頭はGoogleのAPIドメインです)
どちらも「スクリプトからメールを送る」ための関数で、書き味もほとんど同じです。ところが要求する権限が別物なので、GmailApp で承認を通してあるプロジェクトにあとから MailApp を足すと、その呼び出しだけが未承認で例外になります。既存の経路は承認済みのスコープしか使っていないので、最後まで正常に動き続けます。
「一部だけ落ちる」「新しく足した所だけ落ちる」は、権限の切れ目とコードの切れ目が一致しているサインです。
確認: エラーの実文を取り出す
ログは当てにできない
まず clasp tail-logs を試すと、GCPプロジェクトを紐づけていないスクリプトでは拒否されます。
GCP project ID is not set, unable to continue. 紐づけ作業から始めるのは遠回りです。エラーを返してもらうほうが速く終わります。
catch を一時的に詳細版へ差し替える
} catch (error) {
// 一時的に詳細を返す版。確認が終わったら必ず元へ戻す
return ContentService
.createTextOutput(JSON.stringify({ success: false, message: 'DEBUG ' + error.stack }))
.setMimeType(ContentService.MimeType.JSON);
} 叩く側はターミナルから。ウェブアプリのアドレスは変数に逃がしておきます(GAS_ENDPOINT に自分のデプロイ先を入れてください)。
curl -sS -L --post302 -X POST "$GAS_ENDPOINT" \
-H 'Content-Type: application/json' \
--data '{"formType":"template","name":"test"}' GASのウェブアプリはPOSTに対してリダイレクトを返すため、追従させないと空の応答に見えます。使えるオプションは手元の版の curl --help で確認してください。
返ってくるのはこの形です。
DEBUG Exception: ... 権限がありません。必要な権限: .../script.send_mail
at sendTemplateNotificationEmail (Code:439:11)
at doPost (Code:75:7) 必要な権限名と、落ちた関数・行番号がここで確定します。
確認用デプロイのエラーは本番の症状とは限らない
clasp create-deployment で作った確認用のデプロイは、実行ユーザーが本番と違うことがあります。そこで出た(あるいは出なかった)エラーを本番の症状として扱わないでください。本番の挙動を見たいなら、機能は同じでエラー文言だけ詳細にした版を作り、本番デプロイを一時的にそちらへ向けます。
clasp list-deployments # デプロイIDを控える
clasp push --force
clasp create-version "debug: エラー詳細を返す" # → Created version N
clasp update-deployment -V N -d "debug" <deploymentId>
# 確認が終わったら正規版へ戻す
clasp update-deployment -V <正規版> -d "restore" <deploymentId> デプロイIDを指定して更新している限り、ウェブアプリのアドレスは変わりません。新規デプロイにすると変わってしまい、呼び出し側の設定を差し替える羽目になります。
直す: 既存の呼び方に揃える
原因が分かれば直しは1行です。ただし「MailApp のスコープを承認し直す」ではなく、既存が使っているAPIに合わせるほうが速くて事故が少ない。承認画面をもう一度通す必要がなく、プロジェクト内でメール送信の書き方が1つに揃うからです。
書く前に打つべきコマンドはこれだけでした。
grep -n -E 'MailApp|GmailApp|sendEmail' Code.js 既存が GmailApp なら GmailApp で書く。これを1回打つだけで、この記事の詰まりは丸ごと発生しません。同じ用途のAPIが2つある環境では、選ぶ前に既存の呼び方を数える。GASに限らず効く作法です。
直したあと: デグレ確認まで通す
片方を直したら、もう片方が生きているかまで見ます。最低3本です。
# 1) 直した経路
curl -sS -L --post302 -X POST "$GAS_ENDPOINT" -H 'Content-Type: application/json' \
--data '{"formType":"template","name":"test"}'
# 2) 既存の経路(デグレしていないか)
curl -sS -L --post302 -X POST "$GAS_ENDPOINT" -H 'Content-Type: application/json' \
--data '{"formType":"contact","name":"test","message":"test"}'
# 3) ボット対策(隠し項目が埋まっている入力)
curl -sS -L --post302 -X POST "$GAS_ENDPOINT" -H 'Content-Type: application/json' \
--data '{"formType":"contact","name":"test","hp":"bot"}' 隠し項目を使ったボット対策は「静かに受理して何もしない」作りにするのが一般的なので、3本とも success:true が返るのが期待値です。3の内容が保存・送信されていないことまで見て、はじめて確認が終わります。
リリースの順番を間違えない
最後に、直したものを出す順番です。サーバ側(GAS)を先に更新してから、フロントを出します。
フロントを先に本番へ出すと、GASはまだ新しい入力の種類を知らないため、必須項目エラーで弾きます。厄介なのは、フォームの送信を no-cors で投げている構成ではフロントが応答を読めないことです。利用者の画面には「送信しました」と出たまま、保存もメールもされません。黙って消えるのが最悪の形で、しかも問い合わせが来ないので誰も気づきません。
| 出す順番 | 起きること |
|---|---|
| GAS → フロント | 一時的にGASが使われない経路を持つだけ。実害なし |
| フロント → GAS | その間の送信が全部消える。利用者には成功に見える |
まとめ
- GASでメール送信が「追加した経路だけ」落ちるなら、コードではなくOAuthスコープの差を疑う
-
MailAppは.../auth/script.send_mail、GmailAppは.../auth/gmail.sendを要求する。承認済みでない側を足した瞬間にそこだけ落ちる - ログが使えないときは、
catchをerror.stackを返す版へ一時的に差し替えてcurlで叩く。必要な権限名と行番号まで出る - 直し方は「既存の呼び方に
grepで揃える」。書く前の `grep` 1回が最短の予防策 - 出す順番は必ずサーバ側が先
よくある質問(FAQ)
Q. MailApp と GmailApp はどちらを使うべきですか?
新規のプロジェクトならどちらでも構いません。既存のプロジェクトに足す場合は、既に使われている側に必ず揃えてください。混在させると、承認状態によって片方だけが落ちる状態を自分で作り込むことになります。
Q. スコープを承認し直せば MailApp のままでも動きますか?
必要な権限を承認すれば動く方向の話ではありますが、承認画面をもう一度通す手間が増えるうえ、プロジェクト内に2種類の呼び方が残ります。既に動いている側へ寄せるほうが確実です。
Q. なぜ「エラーが発生しました」しか返らないのですか?
catch が例外オブジェクトを捨てて定型の文言だけを返しているためです。原因を知りたい場面では、error.stack を一時的に応答へ載せてください。恒久的に載せると内部の関数名や行番号が外へ出るので、確認が終わったら必ず戻します。
Q. 確認のために本番デプロイを差し替えるのは危なくないですか?
差し替える版は「機能は同じ・エラー文言だけ詳細」に留め、確認が終わったらすぐ正規版へ戻す前提で使ってください。デプロイIDを指定して更新すればアドレスは変わらないので、呼び出し側には影響しません。作業前に clasp list-deployments で戻し先のバージョン番号を控えておくのが安全です。
