GAS『Exceeded Maximum Email Recipients per Day』の原因と対処法

GAS『Exceeded Maximum Email Recipients per Day』の原因と対処法

Google Apps Script(MailApp/GmailApp)で1日に送信できる受信者数の上限を超えると発生する。To/Cc/Bccの総受信者数がカウント対象で、アカウント種別(Google Workspace/個人Gmail)やセキュリティ状態によって上限が異なる。重複宛先や無駄な再送を減らし、送信をチャンク分割して翌日に自動継続する設計に切り替える。

エラーの概要と発生条件

・1日の受信者数(To/Cc/Bccの合計)が上限を超えたときに発生(スクリプト側から送った合計)

・MailApp.sendEmail / GmailApp.sendEmail / GmailApp.createDraft からの送信で累積

・同一メッセージで複数宛先を指定すると、その人数分が加算

・アカウントの種類・状態(個人Gmail/Workspace/新規・試用など)で上限値が異なる

上限の目安(実務での考え方)

・Google Workspace:1ユーザーあたり高めの上限(大量配信には十分とは限らない)

・個人Gmail:上限が低め(通知や小規模な配信向け)

・厳格な審査やセキュリティ要件があると一時的に引き下げられる場合がある

※正確な数値は運用中のテナント・ポリシーに依存するため、実装では“残数を計算して分割送信”を前提にする

数え方の落とし穴(想定より早く上限に達する要因)

・To/Cc/Bccの全員がカウント対象(Bccで隠しても人数は減らない)

・同じ相手へ複数回送っても、そのたびにカウントされる

・添付やHTML本文の有無は上限人数に影響しない(ただし送信時間には影響)

・下書きからの送信も最終的な送信がカウントされる

最短復旧:翌日に自動継続する“キュー&チャンク”方式へ切替

・今日の残り受信者数を見積もり、超過分は送らずキューに残す

・締切前(実行時間の上限前)に自発停止し、時間ベースのトリガーで翌日に再開

・送信対象は事前に重複排除・無効アドレスの除外をして最小化

実装テンプレ:受信者の重複排除とバリデーション

// 重複・空文字・形式不正を除去
function sanitizeRecipients(list) {
  const seen = new Set();
  const out = [];
  const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  for (const raw of list.map(s => String(s || '').trim())) {
    if (!raw || !re.test(raw)) continue;
    const key = raw.toLowerCase();
    if (!seen.has(key)) { seen.add(key); out.push(raw); }
  }
  return out;
}

実装テンプレ:今日の残り枠に合わせてチャンク送信

// 設定:1通あたり To の最大宛先数(Cc/Bccを使う場合は調整)
const MAX_PER_MESSAGE = 50;

// 擬似的に“今日の残枠”をプロパティで運用(実際の上限は環境依存)
function getRemainingQuota() {
  const p = PropertiesService.getScriptProperties();
  const today = new Date().toDateString();
  const state = JSON.parse(p.getProperty('mail_quota') || '{}');
  if (state.date !== today) {
    // 日替わりでリセット(実際のリセット時刻は環境依存なので緩く合わせる)
    p.setProperty('mail_quota', JSON.stringify({ date: today, sent: 0 }));
    return Infinity; // 上限不明のため“制御側で上限を決める”運用にする
  }
  // 運用側で“1日の送信上限(推奨値)”を決める
  const DAILY_BUDGET = Number(p.getProperty('DAILY_BUDGET') || 900); // 例:900
  return Math.max(0, DAILY_BUDGET - (state.sent || 0));
}

function addSentCount(n) {
  const p = PropertiesService.getScriptProperties();
  const today = new Date().toDateString();
  const state = JSON.parse(p.getProperty('mail_quota') || '{}');
  const sent = (state.date === today ? (state.sent || 0) : 0) + n;
  p.setProperty('mail_quota', JSON.stringify({ date: today, sent }));
}

実装テンプレ:本体(分割送信→残りは翌日に継続)

function sendBulkEmails() {
  const p = PropertiesService.getScriptProperties();
  const cursor = Number(p.getProperty('cursor') || 0);

  // 送信先リストを取得する(例:シートや外部ソース)
  const all = sanitizeRecipients(loadRecipientsFromSheet());
  const remain = getRemainingQuota();
  if (remain <= 0) { scheduleNextDay(); return; }

  // 今日送れる分だけ切り出し
  const todays = all.slice(cursor, cursor + remain);
  let i = 0;

  while (i < todays.length) {
    const chunk = todays.slice(i, i + MAX_PER_MESSAGE);
    try {
      MailApp.sendEmail({
        to: chunk.join(','),
        subject: 'お知らせ',
        htmlBody: buildHtmlBody(),
        name: '通知システム'
      });
      addSentCount(chunk.length);
    } catch (e) {
      // 一部失敗時はログと継続(連続エラーが多い場合は停止)
      console.error('send failed', { message: e.message, chunk });
      if (/Exceeded|Limit|Rate/i.test(e.message)) {
        // 送信制限系は即終了→翌日に再開
        p.setProperty('cursor', String(cursor + i));
        scheduleNextDay();
        return;
      }
    }
    i += chunk.length;
    Utilities.sleep(500); // 軽いスロットリング
  }

  // 送り切れなかったら翌日へ
  const nextCursor = cursor + todays.length;
  p.setProperty('cursor', String(nextCursor));
  if (nextCursor < all.length) scheduleNextDay();
  else p.deleteProperty('cursor'); // 完了
}

function scheduleNextDay() {
  // 翌日に再開(“時間ベースのトリガー”を作成)
  ScriptApp.newTrigger('sendBulkEmails').timeBased().after(24 * 60 * 60 * 1000).create();
}

function loadRecipientsFromSheet() {
  const ss = SpreadsheetApp.getActive();
  const sh = ss.getSheetByName('Recipients');
  const vals = sh.getRange(2,1, sh.getLastRow()-1, 1).getValues();
  return vals.map(r => r[0]);
}

function buildHtmlBody() {
  return '<p>お知らせ本文</p>';
}

ドライラン(送信前の見積と安全確認)

// 実際に送らず、今日送れる人数と翌日に回す人数を見積もる
function dryRun() {
  const p = PropertiesService.getScriptProperties();
  const cursor = Number(p.getProperty('cursor') || 0);
  const all = sanitizeRecipients(loadRecipientsFromSheet());
  const remain = getRemainingQuota();
  const canSend = Math.min(remain, Math.max(0, all.length - cursor));
  console.log({
    total: all.length,
    cursor,
    today_can_send: canSend,
    will_remain: all.length - (cursor + canSend)
  });
}

個人情報・到達性の観点(実運用での注意)

・社外大量配信はメール認証(SPF/DKIM/DMARC)が未整備だと到達率が低下しやすい

・同意のない一括配信はポリシー違反やスパム通報のリスク(上限強化の引き金)

・退会・配信停止の導線を必ず用意し、反応の悪い宛先は除外していく

GAS以外の送信基盤を併用する選択肢

・トランザクションメールや大量配信は、SendGrid/Mailgun/AWS SES等の外部サービスをAPI経由で利用

・GASは配信リスト管理・テンプレート生成・API呼び出しのオーケストレーション役に回す

・外部サービスでもレート・日次上限・審査は存在するため、同様にチャンク処理とリトライ設計が必要

よくあるNG→OK早見

・NG:1件ずつsendEmailを連打 → OK:複数宛先を1通に束ねてAPI往復を削減(※人数カウントは減らない)

・NG:重複アドレスをそのまま送信 → OK:sanitizeRecipientsで重複・不正を除去

・NG:上限に当たるまで送り続ける → OK:今日の残枠を見積もり、超過分は翌日に自動継続

・NG:人手で再実行 → OK:cursorとトリガーで自動再開、途中中断にも強い

障害時のログとアラート設計

function alertIfStuck() {
  const p = PropertiesService.getScriptProperties();
  const cursor = p.getProperty('cursor');
  const last = p.getProperty('last_error_time');
  if (last && Date.now() - Number(last) > 6 * 60 * 60 * 1000) {
    MailApp.sendEmail('admin@example.com', '配信キュー停滞', 'cursor=' + cursor);
  }
}

・送信失敗を検知したら、last_error_timeを更新して別トリガーで監視するなどの工夫を入れる

チェックリスト(導入前に)

・送信対象は重複と無効アドレスを除去したか

・“1日の目標送信数(運用上限)”を決め、コードに予算として反映したか

・チャンクサイズとスロットリング(Utilities.sleep)を設定したか

・日替わりのリセットと翌日継続のトリガーを実装したか

・配信停止・除外リストの仕組みがあるか

・外部サービスへの移行や併用が必要な規模かを評価したか