GASメール送信の360秒タイムアウトを防ぐ高速化手法 並列バッチ配信と進捗UIの解説

,
Google Apps Script (GAS) でメール一括配信システムを構築する際、誰もが直面する最大の壁が「6分間(360秒)の実行時間上限」です。

宛先が100件程度であれば同期ループでも数分で送信できますが、顧客リストが200件、300件と増えていくと、1通ごとの通信遅延や画像MIME変換が積み重なり、実測で370秒(6分10秒)に達してタイムアウト強制終了(Exceeded maximum execution time)を引き起こします。

本連載の第4回では、この致命的なボトルネックを根本から解消し、300件の配信を約80〜90秒(約4倍以上の高速化)で安全に完了させる「並列バッチ(チャンク)配信アーキテクチャ」と、ユーザーに安心感を与えるリアルタイム進捗UI・逐次確定保存の実装を詳しく解説します。

並列バッチ配信アーキテクチャ(370秒から約80秒への短縮)

図4-1:従来の直列送信と並列バッチ分散配信のパフォーマンス比較

300件送信で370秒かかるボトルネックの原因分析

まず、なぜ300件のメール送信に約370秒もの時間がかかっていたのか、処理の内訳を計測・分析してみましょう。

処理ステップ 所要時間 ボトルネック要因
Googleドキュメント取得 & 画像抽出 約2〜3秒 初回エクスポート通信(1回のみ)
スプレッドシート状態確認 約1秒 シート全行一括取得(1回のみ)
GmailApp.sendEmail 直列ループ (300回) 約355秒 (96%) 1通あたり約1.18秒 × 300回の通信往復遅延がそのまま直列累積
スプレッドシート日時更新 & 履歴記録 約2秒 実行後の全件一括書き込み

上記の通り、全所要時間の96%以上は「GmailApp.sendEmail」の通信処理です。
1通送信するごとに、HTML本文の構築、画像Blobのエンコード、Gmail APIエンドポイントへのHTTPSリクエスト、Googleサーバー側での送信済みメールボックスへのインデックス作成が発生します。
これを1つのGAS実行内で直列ループさせている限り、物理的な通信遅延の合算によって360秒のリミットを突破することは不可能です。

ブラウザ主導の並列バッチ配信アーキテクチャ

この問題を解決するため、「1回のリクエストで300件全件を送信する」という設計を完全に撤廃しました。
代わりに、ブラウザ(フロントエンド)が司令塔となり、宛先リストを小さなバッチ(チャンク)に分割して、複数のGASワーカーに対して並列で非同期リクエストを送信するアーキテクチャを採用しました。

並列バッチ配信のパラメーター設計

  • バッチサイズ:25件 / 1回
  • 総バッチ数:300件 ÷ 25件 = 12バッチ
  • 並列実行数(多重度):3〜4並列
  • 1バッチの実行時間:約25〜30秒(GASの6分制限に対して安全率12倍!)
  • 総所要時間:3〜4ラウンドで完走 ➔ 約80〜90秒

Google Apps Scriptの同時実行数クォータは通常30個まで許容されているため、3〜4並列の同時リクエストは規約上も完全に安全圏内です。

バッチ送信の中核バックエンド関数(Code.gs)

バックエンドには、25件の宛先を受け取って送信し、結果を返す「sendNewsletterBatch」関数を実装します。
ここで極めて重要なのが、「当該バッチで送信が成功した宛先のみ、直ちにスプレッドシートのD列(前回メール配信)に実行日時を即座に書き込む」という逐次確定ロジックです。

/**
 * 1バッチ(25件)分のメールを送信し、送信成功分をスプレッドシートに即時記録する
 */
function sendNewsletterBatch(batchData) {
  var subject = batchData.subject;
  var recipients = batchData.recipients; // 25件の配列
  var batchIndex = batchData.batchIndex;

  Logger.log('[Batch ' + batchIndex + ' 開始] 対象件数: ' + recipients.length);

  // 1. 本文テンプレートと画像の取得
  var content = buildNewsletterContent();
  var htmlTemplate = content.htmlTemplate;
  var inlineImages = content.inlineImages;

  // 2. 送信元アドレスの確認
  verifySenderAddress();

  // 3. 最新の配信停止状態をスプレッドシートから再取得
  var ss = SpreadsheetApp.openById(SPREADSHEET_ID);
  var sheet = ss.getSheetByName(SHEET_NAME);
  var sheetData = sheet.getDataRange().getValues();
  var statusMap = {};
  for (var r = 1; r < sheetData.length; r++) {
    var email = String(sheetData[r][2] || '').trim();
    if (email) {
      statusMap[email] = isUnsubscribed(sheetData[r][4]);
    }
  }

  var succeededEmails = [];
  var failedList = [];

  // 4. バッチ内の各宛先へ送信
  recipients.forEach(function(rec) {
    var email = rec.email;

    // 送信直前安全チェック:停止の場合はスキップ
    if (statusMap[email] === true) {
      Logger.log('[スキップ] 直前停止宛先: ' + email);
      return;
    }

    try {
      var personalizedHtml = personalize(htmlTemplate, rec.name);
      
      GmailApp.sendEmail(email, subject, PLAIN_FALLBACK, {
        name: SENDER_NAME,
        from: FROM_EMAIL,
        htmlBody: personalizedHtml,
        inlineImages: inlineImages
      });

      succeededEmails.push(email);
    } catch (err) {
      Logger.log('[送信失敗] ' + email + ': ' + err.message);
      failedList.push({
        email: email,
        reason: err.message
      });
    }
  });

  // 5. 【超重要】送信成功した宛先の日時をスプレッドシートへ即時確定保存
  if (succeededEmails.length > 0) {
    recordSentTimestamps(succeededEmails);
  }

  Logger.log('[Batch ' + batchIndex + ' 完了] 成功: ' + succeededEmails.length + ' / 失敗: ' + failedList.length);

  return {
    batchIndex: batchIndex,
    succeeded: succeededEmails,
    failed: failedList
  };
}

リアルタイム進捗バーとキュー管理(フロントエンドJS)

ブラウザ側では、宛先リストをバッチ分割し、最大並列数(MAX_CONCURRENT = 3)を保ちながら次々とリクエストを発行するキュー制御ロジックを実装します。

// フロントエンド並列キュー制御 (index.html内)
var BATCH_SIZE = 25;
var MAX_CONCURRENT = 3;

var sendQueue = [];
var activeWorkers = 0;
var totalRecipientsCount = 0;
var completedCount = 0;
var allSucceeded = [];
var allFailed = [];
var isCancelled = false;

function startParallelBatchDelivery(subject, recipientsList) {
  sendQueue = [];
  completedCount = 0;
  allSucceeded = [];
  allFailed = [];
  isCancelled = false;
  totalRecipientsCount = recipientsList.length;

  // 25件ずつのバッチに分割
  var batchIndex = 1;
  for (var i = 0; i < recipientsList.length; i += BATCH_SIZE) {
    var chunk = recipientsList.slice(i, i + BATCH_SIZE);
    sendQueue.push({
      subject: subject,
      recipients: chunk,
      batchIndex: batchIndex++
    });
  }

  // プログレスモーダルを表示
  showProgressModal(totalRecipientsCount);

  // 初期の並列スロットを埋める
  for (var c = 0; c < MAX_CONCURRENT && sendQueue.length > 0; c++) {
    launchNextBatch();
  }
}

function launchNextBatch() {
  if (isCancelled || sendQueue.length === 0) {
    if (activeWorkers === 0) {
      finishDeliverySession();
    }
    return;
  }

  var batchData = sendQueue.shift();
  activeWorkers++;

  google.script.run
    .withSuccessHandler(function(res) {
      activeWorkers--;
      allSucceeded = allSucceeded.concat(res.succeeded);
      allFailed = allFailed.concat(res.failed);
      completedCount += (res.succeeded.length + res.failed.length);

      updateProgressBar(completedCount, totalRecipientsCount);

      // 空いたスロットに次のバッチを即時投入
      launchNextBatch();
    })
    .withFailureHandler(function(err) {
      activeWorkers--;
      alert('バッチ送信中にエラーが発生しました: ' + err.message);
      launchNextBatch();
    })
    .sendNewsletterBatch(batchData);
}

送信結果モーダルと失敗アドレスのワンクリックコピー機能

全バッチの送信が完了すると、最終サマリーモーダルがポップアップします。
もし存在しないアドレスや受信拒否などで送信に失敗した宛先があった場合、失敗したアドレスとエラー原因を一覧表示し、ワンクリックでクリップボードにコピーできるボタンを設置します。

function finishDeliverySession() {
  hideProgressModal();
  
  // 配信結果サマリーをサーバーに記録
  google.script.run.completeDeliverySession({
    subject: currentSubject,
    succeededCount: allSucceeded.length,
    failedList: allFailed
  });

  // 結果モーダルの表示
  var modal = document.getElementById('resultModal');
  document.getElementById('successCountBadge').textContent = allSucceeded.length + ' 件成功';
  document.getElementById('failedCountBadge').textContent = allFailed.length + ' 件失敗';

  var failedContainer = document.getElementById('failedListContainer');
  if (allFailed.length > 0) {
    failedContainer.style.display = 'block';
    var textList = allFailed.map(function(f) {
      return f.email + ' (' + f.reason + ')';
    }).join('\n');

    document.getElementById('failedListTextarea').value = textList;
  } else {
    failedContainer.style.display = 'none';
  }

  modal.style.display = 'block';
}

function copyFailedEmails() {
  var textarea = document.getElementById('failedListTextarea');
  textarea.select();
  document.execCommand('copy');
  alert('送信失敗リストをクリップボードにコピーしました。\nスプレッドシートの確認・修正にご活用ください。');
}

まとめと次回予告

第4回のまとめ

  • 直列送信による通信遅延の累積が、300件送信における370秒(GAS制限超過)の根本原因。
  • ブラウザ主導で25件バッチ × 3〜4並列実行を行うことで、所要時間を約80〜90秒(約4倍高速)へと劇的に短縮
  • 1バッチあたりの実行時間が約25秒となり、GASの6分制限に対する安全率は12倍に拡大。
  • 各バッチの完了ごとにスプレッドシートの送信日時を逐次確定保存するため、途中切断やブラウザ終了時もデータ整合性を完全保護。
  • 失敗アドレスの自動収集とワンクリックコピー機能により、配信エラーの早期特定と顧客リストのメンテナンス性を向上。

次回(第5回・最終回)は、「GASメルマガの予約配信とセキュリティ対策 自動継続トリガーと誤送信防止機構の解説」をお届けします。
ブラウザを閉じた後でもバックグラウンドで長時間の大量送信を完走させる「自動継続トリガーリレー機構」と、誤送信を鉄壁に防ぐ多層防御セキュリティ仕様を詳しく解説します。


eguchi.netをもっと見る

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