/** * 見本:Googleフォーム送信時に、担当者へ通知+回答者へ自動返信+シートに「通知済み」を記録(Kaname / AI制作) * * 【目的】 * 問い合わせ・申込フォームの回答を見逃さず、回答者にも受付確認メールを自動で返します。 * * 【使い方】 * 1. フォームの回答先スプレッドシートを開き、拡張機能 > Apps Script にこのコードを貼る * 2. SETTINGS を編集 * 3. setupTrigger() を1回実行(「フォーム送信時」トリガーが作られます。権限の承認が必要) * 4. 失敗・保留になった行は retryPending() で再送できます(時間主導トリガーに設定しても可) * 5. メニュー「フォーム通知」>「試し実行(送らない)」で、未通知の件数と1件目のメール文面を、送信も記録もせずに確認できます * * 【設定項目】SETTINGS を参照 * - staffEmails : 担当者への通知先(複数可) * - replyEmailKey : 回答者のメールアドレスを入れた質問の見出し(フォームの質問名と同じにする) * - statusHeader : 記録用の列の見出し(なければ最終列の右に自動追加) * * 【堅牢性】 * - 状態列が空欄の行だけ送る(二重送信防止)。同時実行はロックで直列化。 * - 送信前に MailApp の残り送信可能数を確認。足りなければ「保留」にして後で再送。 * - 回答者のアドレスが不正・空でも担当者通知は送る。送信失敗は「エラー」として記録。 */ const SETTINGS = { staffEmails: ['staff@example.com'], replyEmailKey: 'メールアドレス', senderName: 'お問い合わせ窓口', staffSubject: '[フォーム通知] 新しい回答が届きました', replySubject: 'お問い合わせありがとうございます(自動返信)', replyIntro: 'このたびはお問い合わせいただきありがとうございます。以下の内容で受け付けました。\n担当者より改めてご連絡いたします。', statusHeader: '通知済み', sheetName: '', // 空ならフォーム回答シート(イベントから取得) tz: 'Asia/Tokyo', }; const STATUS_DONE_ = '通知済み'; const STATUS_HOLD_ = '保留(送信上限)'; const STATUS_ERR_ = 'エラー'; /** フォーム送信時トリガーから呼ばれる */ function onFormSubmit(e) { const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const sheet = e.range.getSheet(); processRow_(sheet, e.range.getRow()); } finally { lock.releaseLock(); } } /** 保留・エラーの行をまとめて再送(手動実行 or 時間主導トリガー) */ function retryPending() { const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const ss = SpreadsheetApp.getActive(); const sheet = SETTINGS.sheetName ? ss.getSheetByName(SETTINGS.sheetName) : ss.getSheets()[0]; const col = ensureStatusColumn_(sheet); const last = sheet.getLastRow(); let n = 0; for (let r = 2; r <= last; r++) { const st = String(sheet.getRange(r, col).getValue()); if (st.indexOf(STATUS_HOLD_) === 0 || st.indexOf(STATUS_ERR_) === 0) { sheet.getRange(r, col).setValue(''); // いったん空に戻して通常処理へ processRow_(sheet, r); n++; } } Logger.log(`再送対象 ${n}件`); return n; } finally { lock.releaseLock(); } } /** スプレッドシートを開いたときにメニューを追加 */ function onOpen() { SpreadsheetApp.getUi().createMenu('フォーム通知') .addItem('試し実行(送らない)', 'previewPending') .addItem('保留・エラーを再送', 'retryPending') .addToUi(); } /** 試し実行:何も送らず・何も書かずに、未通知の件数と1件目の文面を返す */ function previewPending() { const ss = SpreadsheetApp.getActive(); const sheet = SETTINGS.sheetName ? ss.getSheetByName(SETTINGS.sheetName) : ss.getSheets()[0]; const lastCol = Math.max(sheet.getLastColumn(), 1); const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0]; const statusIdx = headers.indexOf(SETTINGS.statusHeader); // 列が無くても追加しない const rows = []; for (let r = 2; r <= sheet.getLastRow(); r++) { const values = sheet.getRange(r, 1, 1, lastCol).getValues()[0]; const st = statusIdx >= 0 ? String(values[statusIdx]) : ''; if (st.indexOf(STATUS_DONE_) === 0) continue; const m = buildMessage_(headers, values, statusIdx + 1, r); if (m) rows.push(m); } const first = rows[0]; const text = `未通知・保留・エラーの行:${rows.length}件\n` + `回答者メールが無い・不正:${rows.filter(x => !x.validReply).length}件\n` + `今日の残り送信可能数:${MailApp.getRemainingDailyQuota()}通\n` + (first ? `\n--- 1件目(${first.row}行目)担当者宛 ---\n件名:${SETTINGS.staffSubject}\n${first.staffBody}` + `\n\n--- 回答者宛(${first.validReply ? first.replyTo : '送信しない'}) ---\n件名:${SETTINGS.replySubject}` : ''); Logger.log(text); try { SpreadsheetApp.getUi().alert(text); } catch (e) { /* トリガー等UIなしの実行 */ } return { count: rows.length, first: first || null }; } /** 1行分のメール文面を組み立てる(空行なら null) */ function buildMessage_(headers, values, statusCol, row) { const answers = []; let replyTo = ''; headers.forEach((h, i) => { if (i === statusCol - 1 || h === '') return; const v = formatValue_(values[i]); if (h === SETTINGS.replyEmailKey) replyTo = String(values[i]).trim(); answers.push({ q: String(h), a: v }); }); if (answers.every(x => x.a === '')) return null; const validReply = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(replyTo); const body = answers.map(x => `■ ${x.q}\n${x.a}`).join('\n\n'); return { row, replyTo, validReply, body, staffBody: `フォームに新しい回答がありました(シート ${row} 行目)。\n\n${body}`, replyBody: `${SETTINGS.replyIntro}\n\n--- ご回答内容 ---\n${body}\n\n※このメールは自動送信です。`, }; } function processRow_(sheet, row) { const statusCol = ensureStatusColumn_(sheet); const statusCell = sheet.getRange(row, statusCol); if (String(statusCell.getValue()) !== '') return 'skip'; // 二重送信防止 const lastCol = sheet.getLastColumn(); const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0]; const values = sheet.getRange(row, 1, 1, lastCol).getValues()[0]; const m = buildMessage_(headers, values, statusCol, row); if (!m) return 'empty'; // 空行は何もしない const { replyTo, validReply } = m; const need = SETTINGS.staffEmails.length + (validReply ? 1 : 0); if (MailApp.getRemainingDailyQuota() < need) { statusCell.setValue(STATUS_HOLD_); Logger.log('送信上限のため保留: row ' + row); return 'hold'; } const errors = []; try { MailApp.sendEmail({ to: SETTINGS.staffEmails.join(','), subject: SETTINGS.staffSubject, name: SETTINGS.senderName, body: m.staffBody, replyTo: validReply ? replyTo : '', }); } catch (err) { errors.push('担当者宛: ' + err.message); } if (validReply) { try { MailApp.sendEmail({ to: replyTo, subject: SETTINGS.replySubject, name: SETTINGS.senderName, body: m.replyBody, }); } catch (err) { errors.push('回答者宛: ' + err.message); } } const now = Utilities.formatDate(new Date(), SETTINGS.tz, 'yyyy/MM/dd HH:mm'); if (errors.length) { statusCell.setValue(`${STATUS_ERR_}: ${errors.join(' / ')}`); return 'error'; } statusCell.setValue(`${STATUS_DONE_} ${now}` + (validReply ? '' : '(回答者メールなし)')); return 'done'; } function formatValue_(v) { if (v instanceof Date) return Utilities.formatDate(v, SETTINGS.tz, 'yyyy/MM/dd HH:mm'); return String(v == null ? '' : v).trim(); } /** 状態列の位置を返す(なければ右端に見出しを追加) */ function ensureStatusColumn_(sheet) { const lastCol = Math.max(sheet.getLastColumn(), 1); const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0]; const i = headers.indexOf(SETTINGS.statusHeader); if (i >= 0) return i + 1; sheet.getRange(1, lastCol + 1).setValue(SETTINGS.statusHeader); return lastCol + 1; } /** 初回に1度だけ実行:フォーム送信時トリガーを作成 */ function setupTrigger() { const exists = ScriptApp.getProjectTriggers().some(t => t.getHandlerFunction() === 'onFormSubmit'); if (exists) return; ScriptApp.newTrigger('onFormSubmit').forSpreadsheet(SpreadsheetApp.getActive()).onFormSubmit().create(); }