AIに返信案を作らせても、顧客へ送る前に人が画面で確認する工程は残します。その確認画面で返信案をHTMLとして解釈すると、本文中の記号が表示崩れしたり、意図しないタグとして扱われたりします。
この記事では、Google Apps Script(GAS)のHTML Serviceで返信案をプレビューする最小構成を作ります。目的は送信の自動化ではなく、返信案を文字として表示し、表示結果をテストしてから人へ渡すことです。AI APIの呼び出し、顧客データの保存、送信承認の代行は扱いません。
作るもの
次の流れだけを実装します。
- Sheetsなどから返信案を読む
- HTML Serviceのテンプレートへ表示用データを渡す
- 本文をHTMLとして解釈させず、文字として表示する
- 記号・改行・空欄のテストを通す
- 表示確認後も、送信可否は人が決める
プレビュー画面の状態は、送信権限とは別に管理します。たとえば READY_FOR_REVIEW は「画面に表示できる」という意味であり、「顧客へ送信してよい」という意味にはしません。
まず表示契約を決める
返信案の表示前に、次の値を契約にします。
| 項目 | 許可する状態 | それ以外 |
|---|---|---|
| 本文 | 文字列 | HOLD |
| 案件ID | 空でない文字列 | HOLD |
| 表示状態 | READY_FOR_REVIEW | 送信処理へ進めない |
| HTML | テンプレート側でエスケープ | 未エスケープ挿入を禁止 |
GASのHTMLテンプレートで != value ?> を使うと、値をエスケープせずに挿入できます。AI出力や顧客入力のように信頼できない文字列には使いません。
GAS側の最小実装
まず、シートの1行を表示用モデルへ変換します。表示に必要な値だけを取り出し、送信処理は呼びません。
const PREVIEW_STATUS = 'READY_FOR_REVIEW';
function getReplyPreview(inquiryId) {
const row = findReplyRow_(inquiryId);
if (!row) throw new Error('reply_preview_not_found');
const body = String(row.reply_body || '');
const status = String(row.review_status || '');
if (!body || status !== PREVIEW_STATUS) {
throw new Error('reply_preview_hold');
}
return {
inquiryId: String(row.inquiry_id),
body,
status,
canSend: false,
};
}
function showReplyPreview(inquiryId) {
const template = HtmlService.createTemplateFromFile('reply-preview');
template.model = getReplyPreview(inquiryId);
return template.evaluate().setTitle('返信案の確認');
}
function doGet(event) {
const inquiryId = String(event.parameter.inquiryId || '');
return showReplyPreview(inquiryId);
}
function findReplyRow_(inquiryId) {
// 実運用では列名で行を読み、案件IDの完全一致と重複を検査する。
const fixtures = {
'Q-1001': {
inquiry_id: 'Q-1001',
reply_body: 'ご連絡ありがとうございます。\\n確認して折り返します。',
review_status: PREVIEW_STATUS,
},
};
return fixtures[inquiryId] || null;
}
HTMLテンプレートでは通常の出力を使う
ファイル名を reply-preview.html として、本文をHTMLとして挿入しないテンプレートにします。
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
.reply-body { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<h1>返信案の確認</h1>
<p>案件ID: <?= model.inquiryId ?></p>
<p class="reply-body"><?= model.body ?></p>
<p>状態: <?= model.status ?></p>
<p>この画面は表示確認用です。送信可否は別の人間確認で判断します。</p>
</body>
</html>
- = model.body ?> はHTML Serviceの通常出力に任せる
- white-space: pre-wrap で改行を保持する
- overflow-wrap: anywhere で長いIDやURLの横はみ出しを抑える
- != model.body ?> へ置き換えない
テンプレートで文脈に応じたエスケープを使う場合、サーバー側で同じ値を二重にエスケープしません。手作業の変換とテンプレートの通常出力を混在させると、< や & のような文字が二重変換され、確認者が見た本文と元データの対応を追いにくくなります。
ブラウザ側で値を入れる場合
クライアント側JavaScriptで値を入れる場合は、innerHTML ではなく textContent を使います。
const preview = document.querySelector('#reply-body');
preview.textContent = serverModel.body;
// 使わない
// preview.innerHTML = serverModel.body;
HTMLを許可する仕様が別途ある場合でも、許可タグの一覧、除去処理、テスト、責任者を先に決めます。AI返信案のプレビューでは、HTMLを許可しない方が契約を小さくできます。
表示境界をテストする
きれいな文章だけでなく、記号を含む架空データを使います。顧客の実データやアクセストークンはテストへ入れません。
| ケース | 入力例 | 確認すること |
|---|---|---|
| 通常文 | 確認して折り返します。 | 文章と改行が読める |
| HTML記号 | 重要 & "確認" | タグにならず記号のまま |
| 改行 | 一行目\n二行目 | 改行位置が保持される |
| 長い値 | 連続したIDやURL | 横幅からはみ出さない |
| 空欄 | 空文字 | 表示せずHOLDになる |
| 状態不一致 | DRAFTED | プレビューを返さずHOLDになる |
簡単なテストを、表示ロジックの変更前後で実行します。
function assertEqual_(actual, expected, label) {
if (actual !== expected) {
throw new Error(label + ': expected=' + expected + ', actual=' + actual);
}
}
function testReplyPreviewFixtures_() {
const normal = getReplyPreview('Q-1001');
assertEqual_(normal.status, 'READY_FOR_REVIEW', 'status');
assertEqual_(normal.canSend, false, 'canSend');
if (!normal.body.includes('\\n')) {
throw new Error('newline must remain in the source text');
}
}
function testReplyPreviewHold_() {
let failed = false;
try {
getReplyPreview('missing');
} catch (error) {
failed = error.message === 'reply_preview_not_found';
}
if (!failed) throw new Error('missing inquiry must be held');
}
運用上の境界を分ける
HTMLエスケープを実装しても、返信内容が正しいとは限りません。表示の安全性と業務上の承認を別の判定にします。
- 表示できる:本文を文字として確認画面に出せる
- 確認待ち:担当者が根拠、宛先、期限、対応範囲を見る
- 送信可能:必要な人間確認と承認記録がそろっている
- HOLD:本文空欄、案件ID不一致、状態不正、根拠不足などで人へ戻す
GASに送信処理を追加する場合も、プレビュー関数の canSend: false を、送信ボタンの表示だけで true へ変えないでください。送信権限、承認者、本文の版、確認時刻を別の台帳で照合します。
公開前チェックリスト
- AI出力・顧客入力を innerHTML や != ... ?> で直接挿入していない
- HTML記号、アンパサンド、引用符、改行の架空テストを実行した
- 空欄・案件ID不一致・状態不一致をHOLDにした
- 表示確認と送信承認を別の状態として記録した
- 顧客情報、トークン、Cookie、個人情報をテストデータに入れていない
- 表示結果が正しくても、事実確認や宛先確認を省略していない
HTMLとして見せないという小さな契約を先に置くと、プレビュー画面のテスト範囲と人へ戻す条件を決めやすくなります。AI返信の便利さより先に、確認者が見た文字列を正本の本文と照合できることを優先します。
