🧾事務自動化ラボ

Googleフォームの受付を自動化する(自動返信・担当者通知・受付台帳)

フォームが送信されたら、受付番号付きの自動返信を出し、担当者に通知し、対応ステータス付きの台帳へ1行残すGASのコードです。コピペしても動かない原因になる箇所を、実際に出るエラーごと書いています。

問い合わせフォームをGoogleフォームで作ったあと、運用でつまずくのはたいてい同じ場所です。送った側に何も返らない、担当者が回答シートを見に行かないと気づかない、どれが対応済みか分からなくなる。

Googleフォームの標準機能にも「回答のコピーを回答者に送信」はありますが、これは回答内容の写しが届くだけです。受付番号も、返答の目安も、こちらの案内文も入りません。問い合わせ対応の窓口として使うなら足りません。

この記事では、フォームが送信されたときに次の3つを自動で行うGoogle Apps Script(GAS)を作ります。コードは全文このページに置いています。

  1. 送信者へ、受付番号入りの自動返信メールを出す
  2. 担当者へ、内容を要約した通知メールを出す
  3. 「受付台帳」シートへ、対応ステータス列付きで1行追記する

そして記事の後半は、コピペしたコードが動かない原因に丸ごと割きます。ここは私自身が実際に踏んだ順に書いています。フォーム連携のGASは、書き写しても動かないパターンが決まっていて、しかもエラーが画面に出ないので原因にたどり着きにくいからです。

先に、つまずく箇所を3つ

手順より先に結論を書きます。ここを外すと、他が全部合っていても動きません。

つまずき 何が起きるか
onFormSubmit を書いただけで動くと思っている メール送信が権限エラーで落ちる。画面には何も出ない
スクリプトをフォーム側に貼った e.namedValues が undefined になり TypeError
e.namedValues['お名前'] をそのまま文字列として使った 値は配列。単一回答だと一見動くので、複数選択で初めて壊れる

順に説明します。

手順

1. スクリプトは「フォーム」ではなく「回答スプレッドシート」に貼る

Googleフォームの編集画面「回答」タブから、回答先のスプレッドシートを作ります。スクリプトを貼るのはこのスプレッドシート側です。

スプレッドシートを開いて「拡張機能 > Apps Script」を開き、最初から入っている function myFunction() {} を全部消して、後述のコードを貼り付けます。

なぜフォーム側ではないのか。Apps Scriptの「フォーム送信時」イベントは、スクリプトの紐付け先によって中身がまったく違うからです。公式リファレンスでは次のように分かれています。

バインド先 イベントオブジェクトが持つもの
スプレッドシート namedValues / values / range / authMode / triggerUid
フォーム response(FormResponse オブジェクト) / source / authMode / triggerUid

(出典: Apps Script イベントオブジェクト)

つまりフォーム側に貼ると e.namedValues は存在しません。ネットで拾ったコードが動かない原因のかなりの割合がこれです。この記事のコードはスプレッドシート側前提で書いています。

2. CONFIG をフォームの質問文に合わせる

コード冒頭の CONFIG を書き換えます。

const CONFIG = {
  emailKey: 'メールアドレス',
  nameKey: 'お名前',
  bodyKey: 'お問い合わせ内容',
  // ...
};

ここにはフォームの質問文をそのまま書きます。namedValues のキーは質問文そのものなので、フォーム側で質問文を1文字でも変えるとキーが外れます。「お名前」を「お名前(必須)」に直した瞬間に値が取れなくなる、という壊れ方をします。

3. setupTrigger を1回だけ実行する

エディタ上部の関数選択で setupTrigger を選び、実行します。初回は承認画面が出ます。

  • 「このアプリはGoogleで確認されていません」と出たら、詳細 > (プロジェクト名)に移動 を押します
  • 自分で書いたスクリプトを自分のアカウントで動かす場合の表示です。他人が配布したコードを承認するときは、中身を読んでから進めてください

承認が終わると、setupTrigger がフォーム送信トリガーを登録します。

4. フォームから実際に1件送って確認する

エディタの▶実行ボタンで handleFormSubmit を直接実行しても確認になりません。 イベントオブジェクト e が渡らないためです。フォームのプレビュー(👁アイコン)から実際に送信してください。

コード全文

スプレッドシート側の Apps Script にそのまま貼ります。const や アロー関数を使っているので、ランタイムがV8である必要があります(2020年以降の新規プロジェクトは既定でV8です)。

/**
 * Googleフォーム受付の自動化(自動返信メール・担当者通知・受付台帳)
 *
 * 設置場所: 回答が集まるスプレッドシート側
 *   「拡張機能 > Apps Script」から貼り付ける。フォーム側に貼ると e.namedValues が
 *   存在せず動きません(フォーム側の送信イベントは e.response しか持たないため)。
 *
 * 使い方:
 *   1. CONFIG をフォームの質問文どおりに書き換える
 *   2. setupTrigger を1回だけ実行して承認する
 *   3. フォームから実際に1件送信して確認する
 */

const CONFIG = {
  // フォームの質問文をそのまま書く。1文字でも違うと値が取れません
  emailKey: 'メールアドレス',
  nameKey: 'お名前',
  bodyKey: 'お問い合わせ内容',

  ledgerSheetName: '受付台帳',
  senderName: '事務自動化ラボ',
  replyDeadline: '2営業日以内',
  ticketPrefix: 'R',

  // 空にすると、スクリプトを承認したアカウント宛に通知します
  adminEmail: '',
};

/** トリガーを登録する。既存の同じハンドラは消してから作るので、何度実行しても増えません */
function setupTrigger() {
  ScriptApp.getProjectTriggers()
    .filter(function (t) { return t.getHandlerFunction() === 'handleFormSubmit'; })
    .forEach(function (t) { ScriptApp.deleteTrigger(t); });

  ScriptApp.newTrigger('handleFormSubmit')
    .forSpreadsheet(SpreadsheetApp.getActiveSpreadsheet())
    .onFormSubmit()
    .create();

  SpreadsheetApp.getActiveSpreadsheet().toast('トリガーを登録しました', '受付自動化', 5);
}

/** フォーム送信時に走る本体。installable trigger からのみ呼ばれます */
function handleFormSubmit(e) {
  if (!e || !e.namedValues) {
    throw new Error(
      'イベントオブジェクトがありません。エディタの実行ボタンではなく、フォームから実際に送信して確認してください。'
    );
  }

  const nv = e.namedValues;
  const email = pickValue_(nv, CONFIG.emailKey);
  const name = pickValue_(nv, CONFIG.nameKey) || 'ご担当者';
  const body = pickValue_(nv, CONFIG.bodyKey);
  const ticket = issueTicketNo_();
  const now = new Date();

  // 自動返信。失敗しても台帳の記録は必ず残すため、ここで例外を止めます
  let mailStatus;
  if (!email) {
    mailStatus = '未送信(メール欄が空)';
  } else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
    mailStatus = '未送信(形式が不正)';
  } else {
    try {
      MailApp.sendEmail({
        to: email,
        subject: '【' + CONFIG.senderName + '】お問い合わせを受け付けました(受付番号 ' + ticket + ')',
        body: buildReplyBody_(name, ticket, body),
        name: CONFIG.senderName,
      });
      mailStatus = '送信済み';
    } catch (err) {
      mailStatus = '失敗: ' + err.message;
    }
  }

  try {
    MailApp.sendEmail({
      to: CONFIG.adminEmail || Session.getEffectiveUser().getEmail(),
      subject: '[受付] ' + ticket + ' ' + name + ' 様',
      body: [
        '受付番号: ' + ticket,
        'お名前: ' + name,
        'メール: ' + (email || '(未入力)'),
        '自動返信: ' + mailStatus,
        '',
        '--- 内容 ---',
        body || '(未入力)',
        '',
        '台帳: ' + SpreadsheetApp.getActiveSpreadsheet().getUrl(),
      ].join('\n'),
    });
  } catch (err) {
    console.error('担当者通知に失敗: ' + err.message);
  }

  appendLedger_([
    now,
    ticket,
    name,
    email,
    body,
    mailStatus,
    '未対応',
    '',
    '',
  ]);
}

/**
 * namedValues から安全に1件取り出す。
 * 値は必ず配列で入っており、未回答の任意項目はキーごと存在しないことがあります。
 */
function pickValue_(namedValues, key) {
  const v = namedValues[key];
  if (v === undefined || v === null) return '';
  return String(Array.isArray(v) ? v.join(', ') : v).trim();
}

/**
 * 受付番号を採番する。R20260823-001 の形式で、日付が変わると 001 に戻ります。
 * LockService を使わないと、ほぼ同時に2件送信されたとき同じ番号が出ます。
 */
function issueTicketNo_() {
  const lock = LockService.getScriptLock();
  lock.waitLock(20000);
  try {
    const props = PropertiesService.getScriptProperties();
    const day = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd');
    const key = 'seq_' + day;
    const next = Number(props.getProperty(key) || 0) + 1;
    props.setProperty(key, String(next));
    return CONFIG.ticketPrefix + day + '-' + ('00' + next).slice(-3);
  } finally {
    lock.releaseLock();
  }
}

/** 台帳へ1行追記する。シートが無ければ見出し付きで作ります */
function appendLedger_(row) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sh = ss.getSheetByName(CONFIG.ledgerSheetName);
  if (!sh) {
    sh = ss.insertSheet(CONFIG.ledgerSheetName);
    sh.appendRow(['受付日時', '受付番号', 'お名前', 'メールアドレス', '内容', '自動返信', '対応ステータス', '担当', 'メモ']);
    sh.setFrozenRows(1);
    sh.setColumnWidth(5, 320);
  }
  sh.appendRow(row);
  sh.getRange(sh.getLastRow(), 1).setNumberFormat('yyyy/MM/dd HH:mm');
}

/** 自動返信の本文 */
function buildReplyBody_(name, ticket, body) {
  return [
    name + ' 様',
    '',
    'お問い合わせいただきありがとうございます。',
    '下記の内容で受け付けました。担当より' + CONFIG.replyDeadline + 'にご連絡いたします。',
    '',
    '受付番号: ' + ticket,
    '(お問い合わせの際はこの番号をお知らせください)',
    '',
    '--- いただいた内容 ---',
    body || '(未入力)',
    '----------------------',
    '',
    'このメールは送信時に自動でお送りしています。',
    'このまま返信いただいても担当に届きます。',
    '',
    CONFIG.senderName,
  ].join('\n');
}

/** 送信前の確認用。実際のメールは送らず、返信本文と採番だけを実行ログに出します */
function dryRun() {
  const fake = {};
  fake[CONFIG.nameKey] = ['テスト太郎'];
  fake[CONFIG.emailKey] = ['test@example.com'];
  fake[CONFIG.bodyKey] = ['見積りをお願いします。'];

  console.log('受付番号: ' + issueTicketNo_());
  console.log('お名前: [' + pickValue_(fake, CONFIG.nameKey) + ']');
  console.log('存在しないキー: [' + pickValue_(fake, 'ありません') + ']');
  console.log('残りメール送信可能数: ' + MailApp.getRemainingDailyQuota());
  console.log('--- 返信本文 ---\n' + buildReplyBody_('テスト太郎', 'R00000000-001', '見積りをお願いします。'));
}

つまずいた箇所と、その原因

onFormSubmit という名前で書いても動かない

最初、こう書いて動かしました。

function onFormSubmit(e) {
  MailApp.sendEmail(/* ... */);
}

onOpen や onEdit は関数名を合わせるだけで動くので、同じ調子で書いたわけです。動きませんでした。

理由は2つあります。onFormSubmit はシンプルトリガーではありません。公式ドキュメントがシンプルトリガーとして挙げているのは onOpen / onInstall / onEdit / onSelectionChange / doGet / doPost で、フォーム送信はインストール型トリガー専用です。

そしてもう1つ。仮にシンプルトリガーだったとしても、メールは送れません。公式ドキュメントはこう書いています。

They can't access services that require authorization. For example, a simple trigger can't send an email because the Gmail service requires authorization (認証を必要とするサービスにはアクセスできません。たとえばGmailサービスは認証を必要とするため、シンプルトリガーからメールは送れません)

— Simple triggers

だから、この記事のコードでは関数名を handleFormSubmit にして、setupTrigger から明示的にトリガーを登録しています。名前を onFormSubmit にしないのは、シンプルトリガーと勘違いしないためでもあります。

失敗しても画面には何も出ない

インストール型トリガーの中で例外が起きても、スプレッドシートの画面には何も表示されません。代わりに noreply-apps-scripts-notifications@google.com から失敗通知メールが届きます。これが迷惑メールに入っていたり、通知頻度が日次になっていたりすると、丸1日「送ったのに返信が来ない」状態に気づけません。

対策として、コード側で2つやっています。

  1. 自動返信の失敗を握りつぶさず、台帳の「自動返信」列に記録する。失敗: ... と残るので、シートを見れば送れていない行が分かります
  2. メール送信が失敗しても台帳への追記は必ず実行する。ここで例外を投げると、問い合わせが来た記録ごと消えます
try {
  MailApp.sendEmail({ /* ... */ });
  mailStatus = '送信済み';
} catch (err) {
  mailStatus = '失敗: ' + err.message;
}

過去の実行結果は、エディタ左メニューの「実行数」から確認できます。トリガー実行が失敗していれば、ここに赤で残ります。

ただし「実行数」は自分で見に行かないと分かりません。実行ログを1行ずつ残し、落ちたら自分にメールを出し、トリガーが消えたこと自体を別トリガーで検知する組み方は、GASのトリガーが止まっても気づけるようにする にコード全文を置いています。

e.namedValues の値は配列。しかも単一回答だと動いてしまう

公式ドキュメントの例がそのまま答えです。

{
  'First Name': ['Jane'],
  'Timestamp': ['6/7/2015 20:54:13'],
  'Last Name': ['Doe']
}

値は必ず配列です。ここが厄介なのは、文字列連結すると単一回答では正しく見えてしまう点です。手元で確かめた結果を並べます。

const nv = { 'お名前': ['太郎'], '希望日': ['9/1', '9/2'] };

'お名前は' + nv['お名前']   // "お名前は太郎"      ← 正しく見える
'希望日は' + nv['希望日']   // "希望日は9/1,9/2"   ← 区切りが素の , になる
'氏名は'   + nv['氏名']     // "氏名はundefined"   ← キー違いが本文に出る
nv['氏名'][0]              // TypeError: Cannot read properties of undefined (reading '0')

テストのときは記述式1問しか入れていないので通ってしまい、チェックボックス(複数選択)の質問を追加した回、あるいは質問文を直した回に初めて壊れます。しかも壊れ方が「自動返信の本文に undefined と書かれて相手に届く」なので、こちらは気づきません。

そこで、値の取り出しは必ずこの関数を通しています。

function pickValue_(namedValues, key) {
  const v = namedValues[key];
  if (v === undefined || v === null) return '';
  return String(Array.isArray(v) ? v.join(', ') : v).trim();
}

キーが無ければ空文字を返し、複数選択は , で連結します。未回答の任意項目はキーごと落ちることがあるので、undefined チェックは省けません。

列番号で取ると、フォームを直した日に全部ずれる

e.values[2] のように列番号で取る書き方も見かけますが、これは避けています。フォームに質問を1問挿入すると、その右側の列が全部ずれるからです。質問文をキーにする namedValues なら、挿入では壊れません(代わりに質問文の変更で壊れるので、どちらにせよ CONFIG の見直しは要ります)。

同時送信で受付番号が重複する

採番をスクリプトプロパティのカウンタでやる場合、LockService を使わないと、ほぼ同時に届いた2件が同じ番号を取ります。読み取りと書き込みの間に別の実行が割り込むためです。

const lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
  // 読み取り → +1 → 書き込み
} finally {
  lock.releaseLock();
}

waitLock は待ちきれないと例外を投げます。ここは握りつぶさず、失敗させて通知メールで気づけるようにしています。番号が重複するより、1件失敗して気づくほうが後始末が軽いためです。

トリガーを何度も登録して二重送信になる

トリガーはUIからも作れますが、テストのたびに手で作り直していると、同じハンドラのトリガーが2個3個と残ります。そうなると1回の送信で自動返信が2通届きます。

setupTrigger は、同じハンドラのトリガーを消してから作り直すようにしています。

ScriptApp.getProjectTriggers()
  .filter(function (t) { return t.getHandlerFunction() === 'handleFormSubmit'; })
  .forEach(function (t) { ScriptApp.deleteTrigger(t); });

なお、トリガーの上限は公式の制限表で 20 / ユーザー / スクリプト です。

送信数の上限と、超えたときに起きること

自動返信を付けるということは、Apps Scriptのメール送信枠を使うということです。公式の割り当て表では次のとおりです。

項目 個人アカウント(gmail.com) Google Workspace
1日のメール送信先数 100 / 日 1,500 / 日
トリガーの合計実行時間 90分 / 日 6時間 / 日
1回の実行時間 6分 6分

(出典: Quotas for Google Services)

数えられるのは送信回数ではなく宛先数です。このコードは1件の問い合わせにつき「送信者へ1通 + 担当者へ1通」なので、宛先を2つ消費します。個人アカウントなら1日あたり50件の問い合わせが上限という計算になります。

超えると実行が例外で止まります。エラーは Service invoked too many times for one day: email. の形で出ます。枠は24時間で戻ります。

残数はコードから確認できます。dryRun 関数に入れてあります。

console.log('残りメール送信可能数: ' + MailApp.getRemainingDailyQuota());

問い合わせ件数が多い窓口で使うなら、担当者通知をメールではなく台帳の色付けやChatに寄せるか、Workspaceアカウントで運用するかのどちらかになります。上限に張り付いた状態で運用すると、超えた瞬間に自動返信だけが静かに止まります。

動作確認のしかた

順番があります。

  1. dryRun を実行する。実行ログに受付番号・返信本文・残り送信可能数が出ます。ここではメールを送りません。キー名の設定ミスは、ログの お名前: [] が空かどうかで分かります
  2. setupTrigger を実行して承認する
  3. フォームのプレビューから、自分のメールアドレスで1件送信する
  4. 受信箱を見る(自動返信1通・担当者通知1通)
  5. スプレッドシートに「受付台帳」シートが増え、1行入っているか見る
  6. エディタ左の「実行数」で、handleFormSubmit が完了になっているか見る

3で何も届かないのに6が「完了」になっている場合は、メールアドレスのキー名が合っていない可能性が高いです。台帳の「自動返信」列に 未送信(メール欄が空) と入っているはずなので、そこを見れば切り分けられます。

このコードでやらないこと

  • 添付ファイルの受け取り(フォームのファイルアップロードは回答者のGoogleログインが必要になります。別の設計が要ります)
  • HTMLメールでの自動返信(htmlBody を渡せば可能ですが、受付通知はテキストのほうが事故が少ないと考えて外しています)
  • 対応ステータスの自動更新(「未対応」で入るところまでです。その先は人が触る前提の列にしています)
  • スパム対策(公開フォームに置く場合は、Googleフォーム側の設定やreCAPTCHAの検討が別途必要です)

台帳の「対応ステータス」列だけは手で運用する前提にしています。ここを自動化しようとすると、結局どの状態を正とするかの取り決めが要るためで、フォーム受付の自動化とは別の話になります。


受付の自動化まで来ると、次に効いてくるのはその先の請求です。同じスプレッドシート+GASの組み方で請求書の発行まで組む手順は、スプレッドシートで請求書を自動発行する仕組みの作り方 と、1クリックでPDFにする無料テンプレート に書いています。

問い合わせが増えてきたら

フォームの受付を自動化すると、次に詰まるのは受けたあとの管理です。台帳の行は増えるのに、どれが対応済みでどれが放置されているかは、結局は人が見て判断することになります。

件数が月に数十を超えてきたら、台帳をスプレッドシートで持ち続けるか、顧客管理のツールに移すかの分かれ目です。移す場合は、いま作った台帳のCSVがそのまま取り込み元になります。

← 記事一覧へ