GASメルマガの予約配信とセキュリティ対策 自動継続トリガーと誤送信防止機構の解説

,
第4回ではブラウザ主導の並列バッチ配信による即時送信の高速化を解説しました。
しかし、「明日の朝9時に配信したい」という予約配信の場合、ユーザーはその時間にブラウザを開いているわけではありません。Googleのサーバー上でバックグラウンド実行されるGASタイマートリガーにすべてを委ねることになります。

バックグラウンド実行ではブラウザによる並列分割が使えないため、再び「6分の壁(タイムアウト)」が立ちはだかります。また、自動実行だからこそ、誤送信や情報漏洩を防ぐ厳格なセキュリティ機構が不可欠です。

本連載の最終回(第5回)では、予約配信で何百通・何千通でも確実に完走させる「自動継続トリガーリレー機構」と、誤送信を絶対に防ぐ多層防御(ディフェンス・イン・デプス)セキュリティ仕様を詳しく解説します。

自動継続トリガーリレー構造と多層防御セキュリティ仕様

図5-1:予約配信の自動継続トリガーリレー機構と多層防御セキュリティ設計

GAS時間主導型トリガーによる予約配信の基本メカニズム

予約配信を登録する際、GASの「ScriptApp.newTrigger」を利用して、指定日時に発火するワンショットの時間主導型タイマートリガーを生成します。

/**
 * 配信予約を登録し、タイマートリガーを発行する
 */
function scheduleNewsletter(formData) {
  var subject = formData.subject;
  var scheduledAtStr = formData.scheduledAt; // '2026-09-16T09:00'
  var targetRecipients = formData.recipients;

  var scheduledDate = new Date(scheduledAtStr);
  var now = new Date();
  if (scheduledDate.getTime() <= now.getTime()) {
    throw new Error('配信日時には現在以降の未来の時間を指定してください。');
  }

  // 1. タイマートリガーの作成
  var trigger = ScriptApp.newTrigger('processScheduledDelivery')
    .timeBased()
    .at(scheduledDate)
    .create();

  var triggerId = trigger.getUniqueId();
  var reservationId = 'RES_' + new Date().getTime();

  // 2. 配信履歴シートに「待機中」として保存
  var ss = SpreadsheetApp.openById(SPREADSHEET_ID);
  var sheet = ss.getSheetByName(SCHEDULE_SHEET_NAME);
  sheet.appendRow([
    reservationId,
    new Date(),
    scheduledDate,
    DELIVERY_TYPE_SCHEDULED,
    SCHEDULE_STATUS_PENDING,
    subject,
    targetRecipients.length,
    JSON.stringify(targetRecipients),
    triggerId,
    '配信待機中(トリガー登録完了)'
  ]);

  return {
    success: true,
    reservationId: reservationId,
    scheduledDate: scheduledDate
  };
}

バックグラウンド実行の6分制限を突破する自動継続トリガーリレー

タイマートリガーによって起動された「processScheduledDelivery」は、バックグラウンドの単一スレッドで動作します。
大量送信によって実行時間が6分を超えそうになった場合、プログラム自身が時間を感知して安全に一時中断し、次のトリガーを自律生成してバトンを渡す「自動継続リレー」を構築します。

自動継続トリガーリレーのアルゴリズム

  1. 実行開始時にタイムスタンプ startTime = new Date().getTime() を取得。
  2. 1通送信するごとに経過時間をチェック。
  3. 経過時間が240秒(4分)に達した場合、直ちにループを安全中断(残り2分の余裕を残す)。
  4. 送信完了したアドレスを記録し、未送信の宛先リストを配信履歴シートの「宛先データ(JSON)」列に上書き保存。
  5. 1分後に発火する継続トリガーを自動生成して今回の実行を安全に終了。
  6. 次回の実行時、残りの宛先から送信を再開。
  7. 全件の送信が完了した時点で、継続トリガーを完全消去し、ステータスを「送信済み」に更新。

この自律リレー機構により、宛先が300件、500件、1,000件と増えても、途中でエラー停止することなく何分でも時間をかけて安全に完走します。

/**
 * 予約配信のトリガー実行関数(自動継続リレー付き)
 */
function processScheduledDelivery(e) {
  var startTime = new Date().getTime();
  var TIME_LIMIT_MS = 240 * 1000; // 4分(安全リミット)

  var ss = SpreadsheetApp.openById(SPREADSHEET_ID);
  var historySheet = ss.getSheetByName(SCHEDULE_SHEET_NAME);
  var historyData = historySheet.getDataRange().getValues();

  // 待機中の予約レコードを特定
  var targetRow = -1;
  var reservationData = null;
  for (var i = 1; i < historyData.length; i++) {
    if (historyData[i][4] === SCHEDULE_STATUS_PENDING) {
      targetRow = i + 1;
      reservationData = historyData[i];
      break;
    }
  }

  if (!reservationData) return; // 処理対象なし

  var subject = reservationData[5];
  var remainingRecipients = JSON.parse(reservationData[7] || '[]');
  var content = buildNewsletterContent();
  var succeededList = [];
  var isTimeUp = false;

  // 送信ループ
  while (remainingRecipients.length > 0) {
    // 経過時間チェック
    if (new Date().getTime() - startTime > TIME_LIMIT_MS) {
      isTimeUp = true;
      break;
    }

    var rec = remainingRecipients.shift();
    try {
      var personalizedHtml = personalize(content.htmlTemplate, rec.name);
      GmailApp.sendEmail(rec.email, subject, PLAIN_FALLBACK, {
        name: SENDER_NAME,
        from: FROM_EMAIL,
        htmlBody: personalizedHtml,
        inlineImages: content.inlineImages
      });
      succeededList.push(rec.email);
    } catch (err) {
      Logger.log('[予約配信失敗] ' + rec.email + ': ' + err.message);
    }
  }

  // 成功した宛先の送信日時をスプレッドシートへ即時記録
  if (succeededList.length > 0) {
    recordSentTimestamps(succeededList);
  }

  if (isTimeUp && remainingRecipients.length > 0) {
    // 【時間切れの場合】残りの宛先を保存し、1分後に次トリガーを発行
    Logger.log('4分リミット到達。残り ' + remainingRecipients.length + ' 件を次トリガーへリレーします。');
    historySheet.getRange(targetRow, 8).setValue(JSON.stringify(remainingRecipients));

    ScriptApp.newTrigger('processScheduledDelivery')
      .timeBased()
      .after(60 * 1000)
      .create();
  } else {
    // 【全件完了の場合】ステータスを送信済みにし、トリガーを破棄
    historySheet.getRange(targetRow, 5).setValue(SCHEDULE_STATUS_COMPLETED);
    historySheet.getRange(targetRow, 10).setValue('全宛先への送信が正常完了しました。');
    cleanUpTriggers('processScheduledDelivery');
  }
}

誤配信をプログラムレベルで防ぐ送信元アドレス厳格チェック

Google Workspaceでは、ユーザーが複数のエイリアスや個人アドレスを保持している場合があります。
もし管理者が私用アカウントや別プロジェクトのアドレスで実行してしまった場合、会社の重要メルマガが誤った送信元から送られてしまう重大事故に繋がります。
これを防ぐため、送信直前に「送信元アドレスが指定の正規アドレスと完全一致するか」を厳格に照合します。

/**
 * 送信元アドレスが正規アドレスと一致するか厳格に検証する
 */
function verifySenderAddress() {
  var userEmail = Session.getActiveUser().getEmail();
  var aliases = GmailApp.getAliases();

  // 実行ユーザーのアドレス、または利用可能エイリアスに含まれているか判定
  var isAuthorized = (userEmail === FROM_EMAIL) || (aliases.indexOf(FROM_EMAIL) !== -1);

  if (!isAuthorized) {
    var msg = '【送信拒否】現在の実行ユーザー (' + userEmail + ') は、' +
              '正規送信元アドレス (' + FROM_EMAIL + ') からの送信権限を持っていません。';
    Logger.log(msg);
    throw new Error(msg); // 例外をスローして送信処理を強制遮断
  }
}

送信直前スプレッドシート再読込による配信停止宛先の二重防衛

メルマガ配信で最もクレームやトラブルになりやすいのが「配信停止を希望した顧客への誤配信」です。
画面上で宛先リストを読み込んだ後に顧客から「配信を止めてほしい」と連絡があり、担当者がスプレッドシートのE列を「停止」に変更した場合でも、画面の古い状態のまま送信ボタンが押されてしまうリスクがあります。

これを防ぐため、画面側の除外フィルタに依存せず、送信処理の直前にスプレッドシートの最新データを再取得して突合する「多層防御(ディフェンス・イン・デプス)」を実装します。

// 送信直前のスプレッドシート再突合ロジック
var latestStatusMap = fetchLatestDeliveryStatus();

recipients.forEach(function(rec) {
  // 送信直前に停止へ変更された行は無条件で除外
  if (latestStatusMap[rec.email] === DELIVERY_STATUS_STOPPED) {
    Logger.log('【直前除外】配信停止中のため送信をスキップしました: ' + rec.email);
    return;
  }

  // 安全が確認された宛先のみ送信実行
  sendEmailToRecipient(rec);
});

最小権限の原則に基づくOAuthスコープ定義(appsscript.json)

セキュリティを堅牢にするため、GASプロジェクトのマニフェストファイル「appsscript.json」において、不要な権限を持たせず必要最小限のOAuthスコープのみを明示的に宣言します。

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "DOMAIN"
  },
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.send",
    "https://www.googleapis.com/auth/gmail.readonly",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/documents.readonly",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/script.scriptapp"
  ]
}

システムの透明性を支える3層ログ設計

システムが安定して長期運用されるためには、「何か起きたときに後から完全に追跡できる」ロギング体制が欠かせません。本システムでは3層のログ設計を採用しています。

ログ層 記録場所・タイミング 記録内容と実務効果
第1層:逐次確定ログ 「送信リスト」シート(D列)
各バッチ成功ごと
送信できた顧客にのみ実行日時を即時記録。PC電源断やネットワーク切断時も二重送信を防止。
第2層:配信総合ログ 「配信履歴」シート(1〜10列)
セッション完了時
件名、送信件数、所要時間、失敗メールアドレス一覧およびエラー理由を永続保存。
第3層:即時通知UI Web管理画面の結果モーダル
完了直後
成功・失敗件数のバッジ表示と、失敗アドレスの一括クリップボードコピー機能を提供。

連載の総括と実務まとめ

全5回連載の振り返りまとめ

  • 第1回(全体設計・データ構造):Google Workspace連携の全体像と、「送信リスト」「配信履歴」の構造設計、ステータス自動正規化。
  • 第2回(本文生成・画像CID化):Googleドキュメントを原稿テンプレートとし、エクスポートHTMLのサニタイズとBase64画像のCIDインライン自動変換。
  • 第3回(管理UI・プレビュー・予約):HtmlServiceによるSPA風管理画面、リアルタイム検索、iframe本文プレビュー、即時/予約切り替え。
  • 第4回(並列バッチ・高速化):25件バッチ × 3〜4並列実行により、300件送信を370秒から約80〜90秒(約4倍高速)へ短縮。逐次確定保存。
  • 第5回(予約リレー・セキュリティ):4分リミットを自律検知してバトンを渡す自動継続トリガーリレー、送信元厳格照合、直前再読込の二重防衛。

Google Apps Scriptとスプレッドシート、Googleドキュメントを組み合わせることで、高額な外部配信スタンドを契約することなく、自社の業務フローに完全にフィットした高機能で安全なメルマガ配信システムを構築・運用できます。
ぜひ本連載の仕様とコードを参考に、自社のメルマガ配信の自動化・効率化にお役立てください。


eguchi.netをもっと見る

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