GAS Webアプリで作るメルマガ管理画面 宛先フィルタ・本文プレビューと予約機能の解説

,
メルマガ配信の現場では、「今回誰に送るのかを一覧で確認したい」「送信前に実際の見た目をチェックしたい」「明日の朝9時に自動配信したい」といった操作を直感的に行えるインターフェースが不可欠です。

スプレッドシートの関数やマクロボタンだけでは、複雑な検索やプレビューの表示に限界があります。
そこでGoogle Apps Scriptの「HtmlService」を活用し、モダンでレスポンシブな「シングルページ管理画面(index.html)」を構築します。

本連載の第3回では、宛先テーブルの動的フィルタリング、Googleドキュメントのiframeリアルタイムプレビュー、そして即時配信・予約配信を切り替えるフロントエンドUIの設計と実装を詳しく解説します。

Web管理画面のUI構造とクライアント・サーバー連携

図3-1:Web管理画面のUIレイアウト構造と非同期RPC通信フロー

HtmlServiceで作るSPA風管理画面の全体設計

GASのWebアプリケーションは、「Code.gs」の「doGet」関数でHTMLファイルを読み込み、「HtmlService.createHtmlOutputFromFile」としてブラウザに返すことで動作します。

/**
 * Webアプリケーションのエントリポイント
 */
function doGet() {
  // 配信状況列のデータ入力規則を事前整備
  ensureDeliveryStatusColumn();

  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('メルマガ配信管理システム')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1.0');
}

画面レイアウトは、以下の5つの主要コンポーネントで構成します:

管理画面のUIコンポーネント構成

  • 上部ヘッダー:システム名、Googleドキュメントおよびスプレッドシートへの直接リンクを表示。
  • 配信設定カード:メール件名の入力フォーム、および「即時配信」「配信予約」を選択する視覚的ラジオカード。
  • 本文プレビューアコーディオン:折りたたみ可能なパネル内にiframeを配置し、最新のドキュメント原稿を即座に確認。
  • 宛先一覧カード:キーワードインクリメンタル検索、配信状況フィルタ(配信のみ / 停止のみ / 全員)、件数バッジ、スクロールテーブル。
  • 配信履歴カード:過去の配信ログ一覧、ステータスバッジ(送信済み、待機中等)、予約配信の「取消」ボタン。

宛先テーブルの動的生成とインクリメンタル検索の実装

ページ読み込み時に「google.script.run」を通じてサーバーから宛先データを配列で取得し、DOM上にテーブル行を動的生成します。
検索窓に文字が入力されると、入力値に応じてリアルタイムに表示行を絞り込みます。

// フロントエンド JavaScript (index.html内)
var allRecipients = [];

function fetchRecipients() {
  google.script.run
    .withSuccessHandler(function(recipients) {
      allRecipients = recipients;
      renderRecipients(allRecipients);
      updateSummaryCounts();
    })
    .withFailureHandler(function(err) {
      alert('宛先リストの取得に失敗しました: ' + err.message);
    })
    .getRecipients();
}

function renderRecipients(list) {
  var tbody = document.getElementById('recipientsTableBody');
  tbody.innerHTML = '';

  if (list.length === 0) {
    tbody.innerHTML = '対象の宛先がありません';
    return;
  }

  list.forEach(function(r) {
    var tr = document.createElement('tr');
    if (r.unsubscribed) {
      tr.style.opacity = '0.45';
      tr.style.backgroundColor = '#f1f5f9';
    }

    var statusBadge = r.unsubscribed
      ? '停止'
      : '配信';

    tr.innerHTML = 
      '' + escapeHtml(r.ownerId) + '' +
      '' + escapeHtml(r.name) + '' +
      '' + escapeHtml(r.email) + '' +
      '' + escapeHtml(r.lastSent || '未送信') + '' +
      '' + statusBadge + '';

    tbody.appendChild(tr);
  });
}

配信状況フィルタによる即時絞り込みロジック

「配信のみ(送信対象)」「停止のみ(除外対象)」「全員」を切り替えるセレクトボックスまたはボタングループを設置し、検索キーワードと掛け合わせてフィルタリングを実行します。

function filterRecipients() {
  var keyword = document.getElementById('searchInput').value.trim().toLowerCase();
  var filterStatus = document.getElementById('statusFilter').value; // 'all', 'active', 'stopped'

  var filtered = allRecipients.filter(function(r) {
    var matchesKey = !keyword ||
      r.name.toLowerCase().indexOf(keyword) !== -1 ||
      r.email.toLowerCase().indexOf(keyword) !== -1 ||
      r.ownerId.toLowerCase().indexOf(keyword) !== -1;

    var matchesStatus = true;
    if (filterStatus === 'active') {
      matchesStatus = !r.unsubscribed;
    } else if (filterStatus === 'stopped') {
      matchesStatus = r.unsubscribed;
    }

    return matchesKey && matchesStatus;
  });

  renderRecipients(filtered);
  document.getElementById('displayCountBadge').textContent = filtered.length + ' 件表示中';
}

iframeを活用したGoogleドキュメント本文のリアルタイムプレビュー

メール本文のプレビューは、アコーディオンを開いた瞬間にGASバックエンドから最新HTMLを取得し、iframeの内部ドキュメントへ書き込みます。
iframeに分離することで、ドキュメントのCSSスタイルが管理画面全体のUIデザインと干渉・衝突する事態を完全に防ぎます。

function togglePreview() {
  var panel = document.getElementById('previewPanel');
  var isHidden = panel.style.display === 'none';

  if (isHidden) {
    panel.style.display = 'block';
    loadPreviewContent();
  } else {
    panel.style.display = 'none';
  }
}

function loadPreviewContent() {
  var iframe = document.getElementById('previewIframe');
  var loadingMsg = document.getElementById('previewLoading');
  loadingMsg.style.display = 'block';

  google.script.run
    .withSuccessHandler(function(html) {
      loadingMsg.style.display = 'none';
      var doc = iframe.contentDocument || iframe.contentWindow.document;
      doc.open();
      doc.write(html);
      doc.close();
    })
    .withFailureHandler(function(err) {
      loadingMsg.style.display = 'none';
      alert('プレビュー取得に失敗しました: ' + err.message);
    })
    .getNewsletterPreviewHtml();
}

即時配信と配信予約を切り替えるラジオカードUI

「今すぐ送信したい」場合と、「明日の朝に送信したい」場合をミスなく選択できるよう、大きなカード形式のラジオボタンを採用します。
「配信予約」が選択された場合のみ、日時入力フォーム(datetime-local)がスムーズに展開します。

<!-- 即時配信 / 配信予約の切り替えラジオカード -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin: 15px 0;">
  <label style="border: 2px solid #38bdf8; background-color: #f0f9ff; padding: 15px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px;">
    <input type="radio" name="deliveryType" value="immediate" checked onchange="handleDeliveryTypeChange(this.value)">
    <div>
      <strong style="color: #0284c7; display: block;">今すぐ配信(即時配信)</strong>
      <span style="font-size: 0.85em; color: #64748b;">準備が整い次第、直ちに送信を開始します</span>
    </div>
  </label>
  
  <label style="border: 1px solid #cbd5e1; background-color: #ffffff; padding: 15px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px;">
    <input type="radio" name="deliveryType" value="scheduled" onchange="handleDeliveryTypeChange(this.value)">
    <div>
      <strong style="color: #334155; display: block;">日時を指定して配信(予約配信)</strong>
      <span style="font-size: 0.85em; color: #64748b;">指定した日時にタイマートリガーで自動配信します</span>
    </div>
  </label>
</div>

<!-- 予約日時入力(初期状態は非表示) -->
<div id="scheduleTimeContainer" style="display: none; background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 15px; border-radius: 8px; margin-top: 15px;">
  <label style="font-weight: 500; font-size: 0.9em; display: block; margin-bottom: 6px;">配信予定日時を選択:</label>
  <input type="datetime-local" id="scheduledDateTime" style="padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.95em;">
</div>

過去の配信履歴表示と予約取消機能

管理画面下部には、直近の配信履歴を表示します。
もし誤って予約してしまったり内容を変更したい場合でも、「待機中」の行にのみ表示される「取消」ボタンを押せば、関連するGASのタイマートリガーを即座に破棄して予約を安全にキャンセルできます。

function cancelReservation(reservationId) {
  if (!confirm('この配信予約を取り消しますか?\n(設定済みのトリガーが削除されます)')) return;

  google.script.run
    .withSuccessHandler(function(res) {
      alert('予約を取り消しました。');
      fetchScheduledDeliveries(); // 履歴を再読込
    })
    .withFailureHandler(function(err) {
      alert('予約の取り消しに失敗しました: ' + err.message);
    })
    .cancelScheduledDelivery(reservationId);
}

まとめと次回予告

第3回のまとめ

  • HtmlServiceを用いることで、外部フレームワークを使わずにGAS完結の洗練されたSPA風管理画面を構築可能。
  • 宛先一覧のリアルタイムインクリメンタル検索と配信停止フィルタにより、対象者の事前確認が確実に行える。
  • iframe内でGoogleドキュメント原稿を展開することで、CSSスタイルの干渉を防ぎながら忠実なプレビューを実現。
  • 即時配信・予約配信の切り替えUIと、待機中トリガーのワンクリック取消機能により、運用ミスを大幅に抑制。

次回(第4回)は、本連載の技術的ハイライトとなる「GASメール送信の360秒タイムアウトを防ぐ高速化手法 並列バッチ配信と進捗UIの解説」をお届けします。
300件送信時に約370秒かかりタイムアウト寸前だった処理を、並列バッチアーキテクチャにより「約80秒(4倍以上高速化)」へと大幅短縮したアルゴリズムと進捗プログレスバーの実装手法を詳しく解説します。


eguchi.netをもっと見る

購読すると最新の投稿がメールで送信されます。