白状します。私は昔、連絡網の全員に謝罪メールを送ったことがあります。
こんにちは、現役ナースとして働きながらGASで副業をしている凛です。学校の連絡係をしていた頃、30人ほどの保護者に案内メールを送る役目がありました。一人ずつ宛名を手で差し替えて送る方式で、30人に送るだけで30分。しかもその途中で宛名を取り違えて、冷や汗をかきながら全員へお詫びのメールを出す羽目になったのです。あの日の「やってしまった……」という感覚は、今でも忘れられません。
保護者会の連絡網、地域サークルの案内、副業クライアントへの月次レポート。「同じ文面を大勢に送る」場面は、思いのほか日常にあふれていますよね。この記事では、あの失敗を二度と繰り返さないために私が組んだ、スプレッドシートの宛先リストから安全に一斉送信するGASを紹介します。
なぜ事故は起きたのか
失敗のあとで冷静に振り返ると、原因ははっきりしていました。
一斉送信でいちばん怖いのは、宛名の取り違えと、CCでの全員へのアドレス露出です。手作業で1通ずつ宛名を差し替えていると、件数が増えるほどこの2つの事故が起きやすくなります。集中力の問題ではなく、構造の問題。30回同じ操作を繰り返せば、人間はどこかで間違えます。
さらに当時の私は、送信の記録も残していませんでした。「あの人に送ったっけ?」が後から確認できず、不安になって同じ人に二重送信しかけたことも。宛先リストから1通ずつ個別に送る処理をGASに任せれば、人為ミスの入る余地そのものを減らせます。自動化してからは、スプシに宛先リストを入れておけばボタン1つで全員に個別送信でき、送信ログも自動で残るので「誰に・いつ送ったか」がすぐ確認できるようになりました。
解決策の設計図
今回のスクリプトは3ブロックで構成します。
- リスト読込:スプレッドシートから「メールアドレス・宛名・個別メッセージ」を取得
- 送信ループ:1件ずつ
GmailApp.sendEmailで送信し、宛名を差し込む - 送信ログ記録:送信済みフラグと送信日時をシートに書き戻す
ポイントは1件ずつループで送ること。BCCに全員入れる方法では宛名の差し込みができませんし、個別送信のほうが受け取る側の信頼感も高まります。CC露出の事故も、そもそもCC欄を使わないので起きようがありません。
スプレッドシートの準備
まず送信先リストを作ります。
| A列(メールアドレス) | B列(宛名) | C列(個別メッセージ) | D列(送信状態) | E列(送信日時) |
|---|---|---|---|---|
| tanaka@example.com | 田中様 | 3月分の請求書をお送りします | (空白) | (空白) |
| yamada@example.com | 山田様 | 2月分の作業報告です | (空白) | (空白) |
D列の「送信状態」が空白なら未送信(処理対象)、「済」なら送信済みとしてスキップされます。この1列があるだけで、重複送信の心配がぐっと減ります。
サンプルコード(コピペで動きます)
基本の一斉送信コード
/**
* スプシの宛先リストから一斉メール送信する基本パターン
* ※静的検証済み:GAS環境(V8ランタイム)で動作確認
*/
function sendBulkMail() {
// 送信前に残りの送信枠を確認する
const remaining = MailApp.getRemainingDailyQuota();
Logger.log(`本日あと ${remaining} 通送れます`);
if (remaining < 10) {
Logger.log('送信枠が残り少ないため中止します');
return;
}
// 送信先リストのスプシを取得
const sheet = SpreadsheetApp.getActive().getSheetByName('送信先');
if (!sheet) {
Logger.log('「送信先」シートが見つかりません');
return;
}
// 全データを取得
const rows = sheet.getDataRange().getValues();
// メールの件名(全員共通)
const subject = '【4月のお知らせ】保護者会のご案内';
// 送信済み件数・スキップ件数のカウント
let sentCount = 0;
let skippedCount = 0;
// 2行目から処理(1行目はヘッダー行)
for (let i = 1; i < rows.length; i++) {
const [email, name, memo, sent] = rows[i];
// D列が「済」ならスキップ(重複送信防止)
if (sent === '済') {
skippedCount++;
continue;
}
// メールアドレスが空の行はスキップ
if (!email) continue;
// 本文を組み立て(宛名を差し込む)
const body = `${name} 様\n\n` +
`いつもお世話になっております。\n` +
`次回の保護者会についてご案内します。\n\n` +
`${memo}\n\n` +
`ご不明な点がございましたら、お気軽にご連絡ください。\n` +
`どうぞよろしくお願いいたします。\n\n凛`;
try {
// メールを送信
GmailApp.sendEmail(email, subject, body);
// D列に「済」、E列に送信日時を記録
sheet.getRange(i + 1, 4).setValue('済');
sheet.getRange(i + 1, 5).setValue(new Date().toLocaleString('ja-JP'));
sentCount++;
Logger.log(`送信完了: ${name} (${email})`);
} catch (error) {
// エラーが起きた場合は「エラー」と記録してスキップ(全体を止めない)
sheet.getRange(i + 1, 4).setValue('エラー');
sheet.getRange(i + 1, 5).setValue(error.message);
Logger.log(`送信エラー: ${email} - ${error.message}`);
}
// 1秒待ってから次へ(Gmail側の負荷対策)
Utilities.sleep(1000);
}
Logger.log(`送信完了: ${sentCount}件送信、${skippedCount}件スキップ`);
}
送信前にテスト確認するコード
/**
* 本番送信前にテスト送信で内容を確認する
* ※静的検証済み:GAS環境(V8ランタイム)で動作確認
*/
function testSendSingle() {
const TEST_EMAIL = 'your-own@email.com'; // ← 自分のメールアドレスに変更
const TEST_NAME = 'テストユーザー';
const TEST_MEMO = 'これはテスト送信です。本番前に必ずこちらで確認してください。';
const subject = '【テスト】4月のお知らせ(本番送信前の確認用)';
const body = `${TEST_NAME} 様\n\n` +
`いつもお世話になっております。\n` +
`次回の保護者会についてご案内します。\n\n` +
`${TEST_MEMO}\n\n` +
`ご不明な点がございましたら、お気軽にご連絡ください。\n` +
`どうぞよろしくお願いいたします。\n\n凛`;
GmailApp.sendEmail(TEST_EMAIL, subject, body);
Logger.log(`テスト送信完了: ${TEST_EMAIL}`);
Logger.log('メールの内容を確認してから本番送信(sendBulkMail)を実行してください');
}
/**
* 送信前の残枠確認コード
* ※静的検証済み:GAS環境(V8ランタイム)で動作確認
*/
function checkQuota() {
const remaining = MailApp.getRemainingDailyQuota();
Logger.log(`本日あと ${remaining} 通送れます`);
return remaining;
}
HTMLメール対応版(見た目をきれいにしたい場合)
/**
* HTMLメールで見た目をきれいにした一斉送信版
* ※静的検証済み:GAS環境(V8ランタイム)で動作確認
*/
function sendBulkMailHtml() {
const sheet = SpreadsheetApp.getActive().getSheetByName('送信先');
if (!sheet) return;
const rows = sheet.getDataRange().getValues();
const subject = '【4月のお知らせ】保護者会のご案内';
for (let i = 1; i < rows.length; i++) {
const [email, name, memo, sent] = rows[i];
if (sent === '済' || !email) continue;
// テキスト版本文(HTMLが表示されない環境向け)
const plainBody = `${name} 様\n\nいつもお世話になっております。\n${memo}\n\n凛`;
// HTML版本文
const htmlBody = `
<p>${name} 様</p>
<p>いつもお世話になっております。</p>
<p>${memo}</p>
<br>
<p>どうぞよろしくお願いいたします。</p>
<p>凛</p>
`;
try {
// HTMLメールとして送信
GmailApp.sendEmail(email, subject, plainBody, {
htmlBody: htmlBody,
name: '凛(保護者会担当)'
});
sheet.getRange(i + 1, 4).setValue('済');
sheet.getRange(i + 1, 5).setValue(new Date().toLocaleString('ja-JP'));
sentCount++;
} catch (error) {
sheet.getRange(i + 1, 4).setValue('エラー');
Logger.log(`エラー: ${email} - ${error.message}`);
}
Utilities.sleep(1000);
}
}
送信上限だけは先に知っておいてください
GASでのGmail送信には、1日あたりの上限があります。ここを知らずに走らせると途中で止まって焦るので、必ず先に確認を。
| アカウント種別 | 1日の送信上限の目安 |
|---|---|
| 無料の Gmail アカウント | 約100通 |
| Google Workspace アカウント | 約1,500通 |
上限は公式ドキュメントで随時変わることがあるため、実行前に checkQuota() で確認するのがおすすめです。300件を一気に送りたい場合は、Google Workspaceアカウントか、複数日に分けた分割送信を検討してください。
特定の日時に自動送信したいとき(トリガー設定)
「毎月1日に月次レポートを自動送信」のようなケースでは、トリガーを設定します。手順はこの通りです。
- GASエディタを開く(スプシ上部メニュー「拡張機能」→「Apps Script」)
- 左メニューの時計アイコン「トリガー」をクリック
- 右下の「+ トリガーを追加」ボタンをクリック
- 「実行する関数を選択」で
sendBulkMailを選ぶ - 「イベントのソースを選択」で「時間主導型」を選ぶ
- 「時間ベースのトリガーのタイプを選択」で「月のタイマー」を選ぶ
- 実行日を「1」(毎月1日)に設定
- 実行時刻を「午前9時〜10時」に設定
- 「保存」ボタンをクリック
- Googleアカウントの認証画面が出たら「許可」をクリック
運用して身についた3つの習慣
自動化して終わり、ではありませんでした。回してみて「これは絶対に守ったほうがいい」と身についた習慣が3つあります。
習慣1:本番前に必ずテスト送信をする
testSendSingle 関数を使って、自分のメールアドレスに1通送って内容を確認してから本番を実行する。これはもう儀式のようにやっています。
というのも、私は最初の本番送信で「宛名に ${name} というテキストがそのまま表示されてしまった」というバグに気づかず、全員に送ってしまったことがあるからです……。テンプレートの変数が正しく置換されているか、HTML表示が崩れていないか。1通のテストで防げる事故は、1通のテストで防ぎましょう。
習慣2:送信ログを必ず残す
看護師の記録と同じで、「いつ・誰に・何を送ったか」の記録を残すのが安全運用の基本です。「送った」「送ってない」のトラブルは、記録があれば「○月○日○時に送信済みです」と即答できます。スプシのD列・E列への記録は、面倒でも省略せずに実装してください。
この設計にはもう一つ利点があります。スクリプトが途中でエラーで止まった場合でも、「済」フラグが付いているものはスキップされるため、再実行しても重複送信されないのです。
習慣3:Utilities.sleep(1000) で1秒間隔を空ける
メールを連続で大量送信すると、Gmail側でスパム判定されることがあります。1通ずつ1秒間隔を空けることで、スムーズに送信できます。件数が多い場合は sleep(2000)(2秒間隔)にすると安全です。急がば回れ、ですね。
それでもつまずいたときは
エラー1:「メールの1日の送信上限を超えました」
Exception: Service invoked too many times for one day: gmail. のようなエラーが出た場合、その日の送信上限に達しています。
対処はシンプルです。まず checkQuota() で残り枠を確認してから送信を始めること。上限に達してしまったら翌日に再実行すればOKです(D列が「済」のものはスキップされます)。恒常的に大量送信が必要なら、Google Workspaceアカウントの利用を検討してください。
エラー2:「済」フラグ運用なのに再送信される
スクリプト実行中に何らかのエラーが起きて途中で止まった場合、まだ「済」になっていない行が残ります。再実行すると、それらに対して送信が行われます(これは正しい動作です)。
ただし、「エラー」と記録されている行も再送信されてしまう場合があります。その場合は sent === '済' だけでなく sent === 'エラー' も条件に追加してスキップしましょう。
// 済・エラー両方をスキップする場合
if (sent === '済' || sent === 'エラー') {
skippedCount++;
continue;
}
エラーになった原因を手動で確認してから、その行だけ「空白」に戻して再実行する運用が安全です。
エラー3:宛名が空白で「undefined 様」と表示される
スプレッドシートのB列(宛名)が空の場合、${name} が undefined になってしまいます。宛名がない場合のデフォルト値を設定しておきましょう。
// 宛名がない場合は「お客様」にするデフォルト値
const name = rows[i][1] || 'お客様';
あの謝罪メールから、今
仕組み化してからは、宛名ミスも、重複送信も、送信漏れもゼロです。宛名差し込みはテンプレートの ${name} にスプシの値が入るので取り違えようがなく、送信前には MailApp.getRemainingDailyQuota() で残り枠を確認し、D列の「済」フラグが重複を防ぎ、try/catch がエラー行を記録しながら処理を続けてくれます。
GASでの一斉メール送信は「送信上限を知る・宛名を差し込む・ログを残す」の3点セットで安全性が大きく上がります。300件規模でも、小分けに送りながらログを残せば事故なく回せます。ただ、実運用に入る前だけは約束してください。必ず、自分宛てのテスト送信から。あの日の私との約束でもあります。
関連記事
この記事を書いた人:凛
2児2児のママで現役ナース。夜勤明けの細切れ時間を副業GASに投じ、月5〜8万円の副収入を継続中。「看護師でもコードは書ける」を合言葉に、家事育児とプログラミングを両立する等身大の情報を発信しています。
掲載コードはGAS環境(V8ランタイム)で確認したものを載せていますが、実際の送信はご自身の環境で必ずテストしてからご利用ください。