/***************************************
* 承認ボタン付きメールシステム(公開サンプル版)
* main / date / template / logs 構成
*
* 使い方:
* 1. Googleスプレッドシートを作成
* 2. 拡張機能 → Apps Script を開く
* 3. このコードを丸ごと貼り付けて保存
* 4. スプレッドシートを再読み込み
* 5. 「承認メール」メニューから初期シートを作成
***************************************/
const SHEET_MAIN = 'main';
const SHEET_DATA = 'date';
const SHEET_TEMPLATE = 'template';
const SHEET_LOGS = 'logs';
const STATUS_PENDING = '未承認';
const STATUS_APPROVED = '承認済み';
/**
* スプレッドシートを開いた時にメニューを追加
*/
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('承認メール')
.addItem('初期シート確認・作成', 'setupApprovalMailSystem')
.addSeparator()
.addItem('トークン発行', 'issueTokensForSelectedGroup')
.addItem('送信前プレビュー', 'previewSelectedGroupEmail')
.addItem('テスト送信', 'sendTestEmailForSelectedGroup')
.addSeparator()
.addItem('本番送信', 'sendProductionEmailsForSelectedGroup')
.addSeparator()
.addItem('承認状況サマリー表示', 'showApprovalSummaryForSelectedGroup')
.addToUi();
}
/**
* 初期シート確認・作成
* 既存データは消さず、不足シート・不足列だけ追加
*/
function setupApprovalMailSystem() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let main = ss.getSheetByName(SHEET_MAIN);
if (!main) {
main = ss.insertSheet(SHEET_MAIN);
main.getRange(1, 1, 1, 2).setValues([['設定項目', '設定値']]);
main.getRange(2, 1, 7, 2).setValues([
['送信グループ', 'サンプル案件'],
['件名', '【確認依頼】$1'],
['テスト送信先', 'sample@example.com'],
['WebアプリURL', 'ここにWebアプリURLを貼り付ける'],
['送信者名', 'サンプル事務局'],
['承認ボタン文言', '承認する'],
['承認ボタン色', '#1a73e8']
]);
} else {
ensureMainSettings(main);
}
main.setFrozenRows(1);
main.setColumnWidth(1, 220);
main.setColumnWidth(2, 760);
main.getRange('A:A').setFontWeight('bold');
let data = ss.getSheetByName(SHEET_DATA);
const dataHeaders = [
'区分', '宛先', 'CC', 'BCC',
'$0', '$1', '$2', '$3', '$4', '$5', '$6', '$7', '$8', '$9',
'status', 'approved_at', 'token', 'sent_at'
];
if (!data) {
data = ss.insertSheet(SHEET_DATA);
data.getRange(1, 1, 1, dataHeaders.length).setValues([dataHeaders]);
data.getRange(2, 1, 1, dataHeaders.length).setValues([[
'サンプル案件',
'sample@example.com',
'',
'',
'山田 太郎',
'ホームページ掲載内容の確認',
'掲載予定の文章に問題がないかご確認ください。',
'', '', '', '', '', '', '',
'', '', '', ''
]]);
} else {
ensureHeaders(data, dataHeaders);
}
data.setFrozenRows(1);
data.autoResizeColumns(1, Math.min(data.getLastColumn(), dataHeaders.length));
let template = ss.getSheetByName(SHEET_TEMPLATE);
const templateHeaders = ['件名', 'メール内容', '送信時期', 'メモ'];
if (!template) {
template = ss.insertSheet(SHEET_TEMPLATE);
template.getRange(1, 1, 1, templateHeaders.length).setValues([templateHeaders]);
const defaultBody =
`$0 様
お世話になっております。
サンプル事務局です。
下記の件について、内容をご確認ください。
【確認事項】
$1
$2
問題がなければ、以下の承認ボタンをクリックしてください。
{{承認ボタン}}
内容に修正が必要な場合は、承認ボタンは押さず、このメールに返信してください。
よろしくお願いいたします。
--
サンプル事務局
メール:sample@example.com
URL:https://example.com/`;
template.getRange(2, 1, 1, templateHeaders.length).setValues([[
'【確認依頼】$1',
defaultBody,
'',
'サンプル用テンプレート'
]]);
} else {
ensureTemplateHeaders(template);
}
template.setFrozenRows(1);
template.setColumnWidth(1, 420);
template.setColumnWidth(2, 900);
template.setColumnWidth(3, 180);
template.setColumnWidth(4, 280);
template.getRange('B:B').setWrap(true);
let logs = ss.getSheetByName(SHEET_LOGS);
if (!logs) {
logs = ss.insertSheet(SHEET_LOGS);
logs.getRange(1, 1, 1, 8).setValues([[
'timestamp', 'action', 'group', 'name', 'email', 'status', 'message', 'user_agent'
]]);
}
logs.setFrozenRows(1);
SpreadsheetApp.getUi().alert(
'初期シート確認が完了しました。\n\n' +
'次に、Apps ScriptをWebアプリとしてデプロイし、mainシートの「WebアプリURL」に貼り付けてください。'
);
}
/**
* mainシートに不足設定があれば追加
*/
function ensureMainSettings(sheet) {
const values = sheet.getDataRange().getDisplayValues();
const existingKeys = values.map(row => row[0]);
const defaults = [
['送信グループ', 'サンプル案件'],
['件名', '【確認依頼】$1'],
['テスト送信先', 'sample@example.com'],
['WebアプリURL', 'ここにWebアプリURLを貼り付ける'],
['送信者名', 'サンプル事務局'],
['承認ボタン文言', '承認する'],
['承認ボタン色', '#1a73e8']
];
defaults.forEach(function(item) {
if (!existingKeys.includes(item[0])) {
const nextRow = sheet.getLastRow() + 1;
sheet.getRange(nextRow, 1, 1, 2).setValues([[item[0], item[1]]]);
}
});
}
/**
* 指定ヘッダーが不足していれば右端に追加
*/
function ensureHeaders(sheet, requiredHeaders) {
const lastColumn = Math.max(sheet.getLastColumn(), 1);
const headers = sheet.getRange(1, 1, 1, lastColumn).getDisplayValues()[0];
requiredHeaders.forEach(function(header) {
if (!headers.includes(header)) {
sheet.getRange(1, sheet.getLastColumn() + 1).setValue(header);
}
});
}
/**
* templateシートのヘッダー確認
*/
function ensureTemplateHeaders(sheet) {
ensureHeaders(sheet, ['件名', 'メール内容', '送信時期', 'メモ']);
}
/**
* mainシート設定を取得
*/
function getMainConfig() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_MAIN);
if (!sheet) {
throw new Error('mainシートがありません。先に「初期シート確認・作成」を実行してください。');
}
const values = sheet.getDataRange().getDisplayValues();
const config = {};
for (let i = 1; i < values.length; i++) {
const key = values[i][0];
const value = values[i][1];
if (key) {
config[key] = value;
}
}
return config;
}
/**
* ヘッダーマップ作成
*/
function getHeaderMap(sheet) {
const lastColumn = sheet.getLastColumn();
const headers = sheet.getRange(1, 1, 1, lastColumn).getDisplayValues()[0];
const map = {};
headers.forEach(function(header, index) {
if (header) {
map[String(header)] = index;
}
});
return map;
}
/**
* mainの件名に一致するtemplate行を取得
*/
function getTemplateBySubject(subjectKey) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_TEMPLATE);
if (!sheet) {
throw new Error('templateシートがありません。');
}
const headerMap = getHeaderMap(sheet);
if (headerMap['件名'] === undefined) {
throw new Error('templateシートに「件名」列がありません。');
}
if (headerMap['メール内容'] === undefined) {
throw new Error('templateシートに「メール内容」列がありません。');
}
const values = sheet.getDataRange().getValues();
const matches = [];
for (let i = 1; i < values.length; i++) {
const subject = values[i][headerMap['件名']];
if (String(subject) === String(subjectKey)) {
matches.push({
rowNumber: i + 1,
subject: subject,
body: values[i][headerMap['メール内容']]
});
}
}
if (matches.length === 0) {
throw new Error('templateシートに件名「' + subjectKey + '」が見つかりません。');
}
if (matches.length > 1) {
throw new Error(
'templateシートに同じ件名「' + subjectKey + '」が複数あります。\n\n' +
'件名でメール本文を選択するため、件名は重複しないようにしてください。'
);
}
return matches[0];
}
/**
* mainで選択されている送信グループの行をdateシートから取得
*/
function getRowsBySelectedGroup() {
const config = getMainConfig();
const group = config['送信グループ'];
if (!group) {
throw new Error('mainシートの「送信グループ」が空欄です。');
}
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_DATA);
if (!sheet) {
throw new Error('dateシートがありません。');
}
const headerMap = getHeaderMap(sheet);
if (headerMap['区分'] === undefined) {
throw new Error('dateシートに「区分」列がありません。');
}
const values = sheet.getDataRange().getDisplayValues();
const rows = [];
for (let i = 1; i < values.length; i++) {
const rowGroup = values[i][headerMap['区分']];
if (String(rowGroup) === String(group)) {
rows.push({
rowNumber: i + 1,
values: values[i]
});
}
}
return {
config: config,
group: group,
sheet: sheet,
headerMap: headerMap,
rows: rows
};
}
/**
* 選択グループにトークン発行
*/
function issueTokensForSelectedGroup() {
try {
const data = getRowsBySelectedGroup();
if (data.rows.length === 0) {
SpreadsheetApp.getUi().alert('送信グループ「' + data.group + '」に該当する行がありません。');
return;
}
const tokenCol = getRequiredColumn(data.headerMap, 'token') + 1;
const statusCol = getRequiredColumn(data.headerMap, 'status') + 1;
let issuedCount = 0;
data.rows.forEach(function(item) {
const currentToken = item.values[data.headerMap['token']];
const currentStatus = item.values[data.headerMap['status']];
if (!currentToken) {
data.sheet.getRange(item.rowNumber, tokenCol).setValue(Utilities.getUuid());
issuedCount++;
}
if (!currentStatus) {
data.sheet.getRange(item.rowNumber, statusCol).setValue(STATUS_PENDING);
}
});
SpreadsheetApp.getUi().alert(
'トークン発行完了\n\n' +
'送信グループ:' + data.group + '\n' +
'対象者:' + data.rows.length + '件\n' +
'新規発行:' + issuedCount + '件'
);
} catch (err) {
SpreadsheetApp.getUi().alert(err.message);
}
}
/**
* 送信前プレビュー
*/
function previewSelectedGroupEmail() {
try {
const emailData = buildFirstEmailData(true);
const html = `
プレビュー対象
送信グループ:${escapeHtml(emailData.group)}
template選択件名:${escapeHtml(emailData.templateSubject)}
宛先:${escapeHtml(emailData.to)}
CC:${escapeHtml(emailData.cc)}
BCC:${escapeHtml(emailData.bcc)}
氏名:${escapeHtml(emailData.name)}
送信件名
${escapeHtml(emailData.subject)}
本文プレビュー
${emailData.htmlBody}
※これはプレビューです。メールは送信されていません。
`;
SpreadsheetApp.getUi().showModalDialog(
HtmlService.createHtmlOutput(html).setWidth(820).setHeight(760),
'送信前プレビュー'
);
} catch (err) {
SpreadsheetApp.getUi().alert(err.message);
}
}
/**
* テスト送信
* テスト送信では承認記録を保存しない test_approve URL を使う
*/
function sendTestEmailForSelectedGroup() {
try {
const config = getMainConfig();
const testEmail = config['テスト送信先'];
if (!testEmail || String(testEmail).indexOf('@') === -1) {
SpreadsheetApp.getUi().alert('mainシートの「テスト送信先」にメールアドレスを入力してください。');
return;
}
const emailData = buildFirstEmailData(true);
const htmlBody =
'' +
'これはテスト送信です。
' +
'本番対象者には送信されていません。
' +
'実際の宛先想定:' + escapeHtml(emailData.to) +
'
' +
emailData.htmlBody;
const plainBody =
`【これはテスト送信です】
本番対象者には送信されていません。
実際の宛先想定:${emailData.to}
------------------------------
${emailData.plainBody}`;
MailApp.sendEmail({
to: testEmail,
subject: '【テスト送信】' + emailData.subject,
body: plainBody,
htmlBody: htmlBody,
name: emailData.senderName
});
writeLog('test_send', emailData.group, emailData.name, testEmail, 'sent', 'テスト送信');
SpreadsheetApp.getUi().alert('テスト送信が完了しました。\n\n送信先:' + testEmail);
} catch (err) {
SpreadsheetApp.getUi().alert(err.message);
}
}
/**
* 本番送信
*/
function sendProductionEmailsForSelectedGroup() {
try {
const ui = SpreadsheetApp.getUi();
const data = getRowsBySelectedGroup();
if (data.rows.length === 0) {
ui.alert('送信対象がありません。');
return;
}
const subjectKey = data.config['件名'];
if (!subjectKey) {
ui.alert('mainシートの「件名」が空欄です。');
return;
}
getTemplateBySubject(subjectKey);
const result = ui.alert(
'本番送信確認',
'送信グループ「' + data.group + '」に本番送信します。\n\n' +
'選択件名:' + subjectKey + '\n' +
'対象行:' + data.rows.length + '件\n\n' +
'実行しますか?',
ui.ButtonSet.YES_NO
);
if (result !== ui.Button.YES) {
return;
}
sendEmailsForGroup();
} catch (err) {
SpreadsheetApp.getUi().alert(err.message);
}
}
/**
* グループ送信処理
*/
function sendEmailsForGroup() {
const data = getRowsBySelectedGroup();
const config = data.config;
const webAppUrl = config['WebアプリURL'];
if (!webAppUrl || String(webAppUrl).includes('ここに')) {
throw new Error('mainシートの「WebアプリURL」にデプロイ後のURLを設定してください。');
}
const subjectKey = config['件名'];
if (!subjectKey) {
throw new Error('mainシートの「件名」が空欄です。');
}
const template = getTemplateBySubject(subjectKey);
const statusCol = getRequiredColumn(data.headerMap, 'status') + 1;
const sentAtCol = getRequiredColumn(data.headerMap, 'sent_at') + 1;
const tokenCol = getRequiredColumn(data.headerMap, 'token') + 1;
let sentCount = 0;
let skipCount = 0;
const now = new Date();
data.rows.forEach(function(item) {
let token = item.values[data.headerMap['token']];
const status = item.values[data.headerMap['status']];
const sentAt = item.values[data.headerMap['sent_at']];
if (status === STATUS_APPROVED) {
skipCount++;
return;
}
if (sentAt) {
skipCount++;
return;
}
if (!token) {
token = Utilities.getUuid();
data.sheet.getRange(item.rowNumber, tokenCol).setValue(token);
item.values[data.headerMap['token']] = token;
}
if (!status) {
data.sheet.getRange(item.rowNumber, statusCol).setValue(STATUS_PENDING);
item.values[data.headerMap['status']] = STATUS_PENDING;
}
const emailData = buildEmailDataFromRow(
data.group,
item.values,
data.headerMap,
config,
template,
false
);
if (!emailData.to) {
skipCount++;
return;
}
const options = {
to: emailData.to,
subject: emailData.subject,
body: emailData.plainBody,
htmlBody: emailData.htmlBody,
name: emailData.senderName
};
if (emailData.cc) {
options.cc = emailData.cc;
}
if (emailData.bcc) {
options.bcc = emailData.bcc;
}
MailApp.sendEmail(options);
data.sheet.getRange(item.rowNumber, sentAtCol).setValue(now);
writeLog('send', data.group, emailData.name, emailData.to, 'sent', '本番送信');
sentCount++;
});
SpreadsheetApp.getUi().alert(
'本番送信完了\n\n' +
'送信グループ:' + data.group + '\n' +
'送信:' + sentCount + '件\n' +
'スキップ:' + skipCount + '件'
);
}
/**
* プレビュー・テスト送信用に、選択グループの最初の有効行でメールデータ作成
*/
function buildFirstEmailData(isTest) {
const data = getRowsBySelectedGroup();
if (data.rows.length === 0) {
throw new Error('送信グループ「' + data.group + '」に該当する行がdateシートにありません。');
}
const subjectKey = data.config['件名'];
if (!subjectKey) {
throw new Error('mainシートの「件名」が空欄です。');
}
const template = getTemplateBySubject(subjectKey);
const tokenCol = getRequiredColumn(data.headerMap, 'token') + 1;
const statusCol = getRequiredColumn(data.headerMap, 'status') + 1;
const first = data.rows[0];
if (!first.values[data.headerMap['token']]) {
const newToken = Utilities.getUuid();
data.sheet.getRange(first.rowNumber, tokenCol).setValue(newToken);
first.values[data.headerMap['token']] = newToken;
}
if (!first.values[data.headerMap['status']]) {
data.sheet.getRange(first.rowNumber, statusCol).setValue(STATUS_PENDING);
first.values[data.headerMap['status']] = STATUS_PENDING;
}
return buildEmailDataFromRow(
data.group,
first.values,
data.headerMap,
data.config,
template,
isTest
);
}
/**
* 1行分のメールデータ作成
*/
function buildEmailDataFromRow(group, rowValues, headerMap, config, template, isTest) {
const to = getValue(rowValues, headerMap, '宛先');
const cc = getValue(rowValues, headerMap, 'CC');
const bcc = getValue(rowValues, headerMap, 'BCC');
const token = getValue(rowValues, headerMap, 'token');
const webAppUrl = config['WebアプリURL'];
if (!webAppUrl || String(webAppUrl).includes('ここに')) {
throw new Error('mainシートの「WebアプリURL」にデプロイ後のURLを設定してください。');
}
const action = isTest ? 'test_approve' : 'approve';
// 送信グループ名をURLに含めず、tokenだけで承認対象を判定します。
const approveUrl =
webAppUrl +
'?action=' + encodeURIComponent(action) +
'&token=' + encodeURIComponent(token);
const buttonLabel = config['承認ボタン文言'] || '承認する';
const buttonColor = sanitizeColor(config['承認ボタン色'] || '#1a73e8');
const approveButtonHtml = createApproveButtonHtml(approveUrl, buttonLabel, buttonColor);
const rowMapPlain = buildRowMap(rowValues, headerMap, false);
const rowMapHtml = buildRowMap(rowValues, headerMap, true);
rowMapPlain['承認URL'] = approveUrl;
rowMapPlain['承認ボタン'] = approveUrl;
rowMapHtml['承認URL'] = escapeHtml(approveUrl);
rowMapHtml['承認ボタン'] = approveButtonHtml;
const subject = mergeTemplate(template.subject, rowMapPlain);
const plainBody = mergeTemplate(template.body, rowMapPlain);
const htmlBody = createHtmlMailBody(
mergeTemplate(template.body, rowMapHtml)
);
const warnings = findUnreplacedTags(subject + '\n' + plainBody);
if (warnings.length > 0) {
Logger.log('未置換タグがあります: ' + warnings.join(', '));
}
return {
group: group,
templateSubject: template.subject,
to: to,
cc: cc,
bcc: bcc,
name: getValue(rowValues, headerMap, '$0'),
subject: subject,
plainBody: plainBody,
htmlBody: htmlBody,
senderName: config['送信者名'] || 'サンプル事務局'
};
}
/**
* 行データを差し込み用Mapに変換
*/
function buildRowMap(rowValues, headerMap, htmlMode) {
const map = {};
Object.keys(headerMap).forEach(function(header) {
const rawValue = rowValues[headerMap[header]];
map[header] = htmlMode ? escapeHtml(rawValue) : String(rawValue || '');
});
return map;
}
/**
* テンプレート差し込み
*/
function mergeTemplate(templateText, map) {
let result = String(templateText || '');
const dollarKeys = Object.keys(map)
.filter(function(key) {
return /^\$\d+$/.test(key);
})
.sort(function(a, b) {
return Number(b.substring(1)) - Number(a.substring(1));
});
dollarKeys.forEach(function(key) {
result = result.split(key).join(map[key]);
});
Object.keys(map).forEach(function(key) {
result = result.split('{{' + key + '}}').join(map[key]);
});
return result;
}
/**
* 未置換タグ検出
*/
function findUnreplacedTags(text) {
const result = [];
const regex = /{{[^}]+}}|\$\d+/g;
let match;
while ((match = regex.exec(text)) !== null) {
if (!result.includes(match[0])) {
result.push(match[0]);
}
}
return result;
}
/**
* 承認ボタンHTML
*/
function createApproveButtonHtml(approveUrl, label, color) {
return '' +
'' +
escapeHtml(label) +
'' +
'
' +
'' +
'ボタンが押せない場合は、以下のURLをブラウザに貼り付けてください。
' +
'' + escapeHtml(approveUrl) + '' +
'
';
}
/**
* ボタン色の簡易チェック
*/
function sanitizeColor(color) {
const value = String(color || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
return value;
}
if (/^#[0-9a-fA-F]{3}$/.test(value)) {
return value;
}
return '#1a73e8';
}
/**
* HTMLメール本文化
*/
function createHtmlMailBody(body) {
return '' +
String(body || '').replace(/\n/g, '
') +
'
';
}
/**
* 承認URLクリック時
*/
function doGet(e) {
const action = e && e.parameter ? e.parameter.action : '';
const token = e && e.parameter ? e.parameter.token : '';
if (action === 'test_approve') {
return HtmlService.createHtmlOutput(createMessageHtml(
'テスト用承認ボタンです',
'これはテスト送信用のボタンです。承認記録は保存されていません。'
));
}
if (action !== 'approve') {
return HtmlService.createHtmlOutput(createMessageHtml(
'無効なアクセスです',
'承認用URLが正しくありません。'
));
}
if (!token) {
return HtmlService.createHtmlOutput(createMessageHtml(
'承認できませんでした',
'必要な情報が不足しています。'
));
}
const result = approveByToken(token, e);
return HtmlService.createHtmlOutput(createMessageHtml(
result.title,
result.message
));
}
/**
* 承認処理
*/
function approveByToken(token, e) {
const lock = LockService.getScriptLock();
try {
lock.waitLock(10000);
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_DATA);
if (!sheet) {
return {
success: false,
title: '承認できませんでした',
message: 'dateシートが見つかりません。'
};
}
const headerMap = getHeaderMap(sheet);
const groupIndex = getRequiredColumn(headerMap, '区分');
const tokenIndex = getRequiredColumn(headerMap, 'token');
const statusIndex = getRequiredColumn(headerMap, 'status');
const approvedAtIndex = getRequiredColumn(headerMap, 'approved_at');
const nameIndex = headerMap['$0'];
const emailIndex = headerMap['宛先'];
const values = sheet.getDataRange().getDisplayValues();
for (let i = 1; i < values.length; i++) {
const rowToken = values[i][tokenIndex];
if (String(rowToken) === String(token)) {
const group = values[i][groupIndex];
const name = nameIndex !== undefined ? values[i][nameIndex] : '';
const email = emailIndex !== undefined ? values[i][emailIndex] : '';
const status = values[i][statusIndex];
if (status === STATUS_APPROVED) {
writeLog('duplicate_approve', group, name, email, status, 'すでに承認済み', getUserAgent(e));
return {
success: true,
title: '承認済みです',
message: 'この確認依頼はすでに承認済みです。'
};
}
const now = new Date();
sheet.getRange(i + 1, statusIndex + 1).setValue(STATUS_APPROVED);
sheet.getRange(i + 1, approvedAtIndex + 1).setValue(now);
writeLog('approve', group, name, email, STATUS_APPROVED, '承認完了', getUserAgent(e));
return {
success: true,
title: '承認を受け付けました',
message: 'ご対応ありがとうございました。この画面は閉じていただいて構いません。'
};
}
}
return {
success: false,
title: '承認できませんでした',
message: '承認対象が見つかりませんでした。URLが無効、または期限切れの可能性があります。'
};
} catch (err) {
return {
success: false,
title: '承認できませんでした',
message: '処理中にエラーが発生しました。しばらくしてから再度お試しください。'
};
} finally {
try {
lock.releaseLock();
} catch (err) {}
}
}
/**
* 承認状況サマリー
*/
function showApprovalSummaryForSelectedGroup() {
try {
const data = getRowsBySelectedGroup();
if (data.rows.length === 0) {
SpreadsheetApp.getUi().alert('送信グループ「' + data.group + '」に該当する行がありません。');
return;
}
const statusIndex = getRequiredColumn(data.headerMap, 'status');
let total = 0;
let approved = 0;
let pending = 0;
data.rows.forEach(function(item) {
const status = item.values[statusIndex];
total++;
if (status === STATUS_APPROVED) {
approved++;
} else {
pending++;
}
});
SpreadsheetApp.getUi().alert(
'承認状況\n\n' +
'送信グループ:' + data.group + '\n' +
'対象者:' + total + '件\n' +
'承認済:' + approved + '件\n' +
'未承認:' + pending + '件'
);
} catch (err) {
SpreadsheetApp.getUi().alert(err.message);
}
}
/**
* ログ記録
*/
function writeLog(action, group, name, email, status, message, userAgent) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(SHEET_LOGS);
if (!sheet) {
sheet = ss.insertSheet(SHEET_LOGS);
sheet.getRange(1, 1, 1, 8).setValues([[
'timestamp',
'action',
'group',
'name',
'email',
'status',
'message',
'user_agent'
]]);
}
sheet.appendRow([
new Date(),
action,
group,
name,
email,
status,
message,
userAgent || ''
]);
}
/**
* 必須列取得
*/
function getRequiredColumn(headerMap, headerName) {
if (headerMap[headerName] === undefined) {
throw new Error('必要な列「' + headerName + '」が見つかりません。');
}
return headerMap[headerName];
}
/**
* 値取得
*/
function getValue(rowValues, headerMap, headerName) {
if (headerMap[headerName] === undefined) {
return '';
}
return String(rowValues[headerMap[headerName]] || '');
}
/**
* ユーザーエージェント取得
*/
function getUserAgent(e) {
try {
return e && e.headers && e.headers['User-Agent'] ? e.headers['User-Agent'] : '';
} catch (err) {
return '';
}
}
/**
* 完了画面HTML
*/
function createMessageHtml(title, message) {
return `
${escapeHtml(title)}
${escapeHtml(title)}
${escapeHtml(message)}
この画面は閉じていただいて構いません。
`;
}
/**
* HTMLエスケープ
*/
function escapeHtml(text) {
if (text === null || text === undefined) {
return '';
}
return String(text)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}