Отправка писем через Gmail

Как разослать двести персональных писем прямо из таблицы, ни разу не открыв Gmail руками — и не отправить половину людей дважды.

MailApp — служба Apps Script, которая умеет только одно: отправлять письма от вашего имени. GmailApp — её старшая сестра: она отправляет письма и вдобавок читает почту, работает с черновиками, ярлыками и цепочками.

Зачем это нужно

Практически любая офисная рутина рано или поздно упирается в письмо. Бухгалтер каждый месяц рассылает сотрудникам расчётные листки. Менеджер напоминает клиентам о задолженности. Организатор шлёт участникам конференции индивидуальные ссылки на подключение. Во всех этих случаях исходные данные уже лежат в таблице — фамилии, адреса, суммы, сроки, — а человек тратит два часа на то, чтобы механически перенести их в почту.

Такая рассылка называется mail merge — «слияние писем»: берём шаблон с пропусками и подставляем в него значения из каждой строки. Apps Script делает это за секунды, потому что таблица и почта живут в одном аккаунте Google и скрипту не нужны ни пароли, ни SMTP-настройки, ни сторонние сервисы.

MailApp или GmailApp — что выбрать

Обе службы умеют отправлять письма, и синтаксис у них почти одинаковый. Разница — в правах доступа и в побочных эффектах.

КритерийMailAppGmailApp
Что умееттолько отправлятьотправлять, читать, искать, работать с черновиками и ярлыками
Какие права запросит у пользователяузкие: «отправлять письма от вашего имени»широкие: «читать, изменять, отправлять и удалять почту»
Письмо попадает в «Отправленные»обычно нетда
Когда братьрассылки, уведомления, отчётыкогда нужно разбирать входящие или готовить черновики на проверку

Правило простое: если задача — «просто отправить», берите MailApp. Ваши коллеги увидят при первом запуске куда менее пугающее окно согласия. Просить право удалять чужую почту ради того, чтобы отправить напоминание об оплате, — плохая инженерная манера.

Первое письмо

Самый короткий рабочий скрипт выглядит так. Обратите внимание: у MailApp.sendEmail есть форма с объектом-параметром — она читаемее, чем позиционные аргументы, и именно её стоит использовать.

function sendTestEmail() {
  MailApp.sendEmail({
    to: '[email protected]',
    subject: 'Проверка связи',
    htmlBody: '<p>Привет! Это письмо отправил <b>Apps Script</b>.</p>',
    name: 'Бухгалтерия «Ромашка»'
  });
}

Ключ htmlBody — это HTML-версия письма: жирный шрифт, ссылки, таблицы. Ключ body — текстовая версия для почтовых клиентов, которые HTML не показывают. Если вы положите HTML в body, получатель увидит не жирный текст, а сырые угловые скобки — это ошибка новичка номер один. Поле name подменяет отображаемое имя отправителя (адрес остаётся вашим).

Mail merge: рассылка по строкам таблицы

Теперь настоящая задача. На листе «Должники» есть колонки Email, Имя, Сумма, Срок, Статус. Нужно отправить каждому персональное напоминание и пометить строку, чтобы при повторном запуске письмо не ушло второй раз.

function sendReminders() {
  const sheet = SpreadsheetApp.getActive().getSheetByName('Должники');
  const data = sheet.getDataRange().getDisplayValues();
  const header = data.shift();

  const emailCol = header.indexOf('Email');
  const statusCol = header.indexOf('Статус');
  const template = '<p>Здравствуйте, {{Имя}}!</p>'
    + '<p>За вами числится {{Сумма}} ₽. Оплатить нужно до {{Срок}}.</p>';

  const quota = MailApp.getRemainingDailyQuota();
  if (quota < data.length) {
    throw new Error('Не хватит квоты: нужно ' + data.length + ', осталось ' + quota);
  }

  data.forEach(function (row, i) {
    const rowNumber = i + 2;
    if (!row[emailCol] || row[statusCol] === 'отправлено') return;

    const record = {};
    header.forEach(function (name, j) { record[name] = row[j]; });

    const html = fillTemplate(template, record);
    MailApp.sendEmail({
      to: row[emailCol],
      subject: 'Напоминание об оплате',
      htmlBody: html,
      body: html.replace(/<[^>]+>/g, ''),
      name: 'Бухгалтерия «Ромашка»'
    });

    sheet.getRange(rowNumber, statusCol + 1).setValue('отправлено');
    SpreadsheetApp.flush();
  });
}

function fillTemplate(template, record) {
  return template.replace(/{{(.+?)}}/g, function (match, key) {
    const value = record[key.trim()];
    return value === undefined ? match : String(value);
  });
}

Разбор по строчкам

  • getDisplayValues(), а не getValues(). Первый возвращает строки ровно в том виде, в каком их видит человек: «14.07.2026», «12 500,00 ₽». Второй вернёт объект Date и число 12500, и в письме появится «Mon Jul 14 2026 00:00:00 GMT+0300». Для писем почти всегда нужен именно «человеческий» вид.
  • data.shift() отрезает строку заголовков. После этого индекс i = 0 соответствует второй строке листа — отсюда rowNumber = i + 2.
  • Мы ищем номера колонок по именам через indexOf, а не пишем row[0]. Если завтра кто-то вставит колонку в середину, скрипт продолжит работать.
  • fillTemplate заменяет все {{Имя}} на значения. Если ключа в записи нет — оставляем плейсхолдер как есть: пусть в письме будет видно «{{Скидка}}», чем тихое «undefined».
  • body: html.replace(...) — грубая, но рабочая текстовая версия: вырезаем теги.
  • Отметка «отправлено» ставится сразу после каждого письма, а не одним setValues в конце. Это ключевое решение — см. ниже.
  • SpreadsheetApp.flush() заставляет Google немедленно записать изменение в таблицу. Без него отметки могут висеть в буфере, и если скрипт упадёт по таймауту, они не сохранятся.

Идемпотентность: почему отметка «сразу»

Скрипт может упасть посреди рассылки: сеть моргнула, кончилась квота, истекли 6 минут на выполнение. Если вы собирали статусы в массив и планировали записать их одним махом в конце — этот код никогда не выполнится, а 80 писем уже ушли. Пользователь нажмёт «Запустить» ещё раз, и те же 80 человек получат письмо повторно. Для напоминания об оплате это неприятно, для приглашения — позор.

Свойство «повторный запуск не делает лишней работы» называется идемпотентностью. Достигается она ровно так: отметка о выполненном шаге фиксируется немедленно, а на входе шаг пропускается, если отметка уже есть.

Вложения

Ключ attachments принимает массив Blob — «сырых» файлов в памяти. Взять их можно из Диска или сгенерировать на лету.

function sendReportWithAttachment(email) {
  const pdf = DriveApp.getFileById('FILE_ID').getAs('application/pdf');
  const csv = Utilities.newBlob('Дата,Сумма\n14.07.2026,12500', 'text/csv', 'otchet.csv');

  MailApp.sendEmail({
    to: email,
    subject: 'Отчёт за июль',
    htmlBody: '<p>Отчёт во вложении. Логотип: <img src="cid:logo"></p>',
    attachments: [pdf, csv],
    inlineImages: { logo: DriveApp.getFileById('LOGO_ID').getBlob() }
  });
}

inlineImages — картинки внутри тела письма: ключ объекта становится идентификатором cid: в HTML. Так делают фирменные шапки писем.

Как это работает

Скрипт выполняется не в вашем браузере, а на серверах Google, от имени того аккаунта, который дал разрешение. Отсюда два следствия, о которых обязательно нужно помнить.

Первое: письма уходят с вашего адреса. Если вы повесите на скрипт триггер по времени, письма будут уходить от имени того, кто создал триггер, даже если таблицу в этот момент открыл кто-то другой. Коллега, случайно пересоздавший триггер, начнёт рассылать письма от себя.

Второе: есть суточная квота. И считается она по получателям, а не по письмам. Письмо с тремя адресами в to и одним в cc съедает четыре единицы квоты.

Тип аккаунтаПолучателей писем в сутки
Обычный @gmail.com100
Google Workspace (корпоративный)1500

Остаток всегда можно спросить: MailApp.getRemainingDailyQuota(). Именно это мы и делаем в начале sendReminders — лучше отказаться от рассылки заранее, чем разослать её наполовину. Квота восстанавливается в течение суток, но не «в полночь по вашему часовому поясу» — это скользящее окно, точного момента сброса ждать не стоит. При превышении вы получите исключение вида Service invoked too many times for one day: email.

Есть и второй лимит — 6 минут на одно выполнение (для Workspace — 30 минут в некоторых сценариях). Рассылка на 500 строк со setValue на каждой в него не уложится. Решение — разбивать работу на порции: обрабатывать по 50 строк за запуск и вешать триггер, который вернётся к остатку через несколько минут. Пропуск уже отмеченных строк, который мы заложили, делает такую «порционность» бесплатной.

Частые ошибки

  • Отметка статуса в конце цикла. Падение на середине — и половина адресатов получает дубль.
  • HTML в поле body. Получатель видит теги. HTML идёт только в htmlBody.
  • Сырые getValues() в шаблоне. Даты превращаются в «Mon Jul 14 2026…», деньги — в «12500». Берите getDisplayValues() или форматируйте через Utilities.formatDate.
  • Не проверять квоту. Рассылка обрывается на 100-м адресе, и вы узнаёте об этом от клиентов.
  • Жёсткие индексы колонок (row[3]). Первая же вставленная колонка ломает рассылку молча — письма уйдут с чужими суммами.
  • Пустые адреса. Строка без Email уронит скрипт исключением. Проверяйте перед отправкой.
  • Использовать Apps Script как маркетинговую рассылалку. Он для этого не предназначен: нет отписки, нет статистики, зато есть риск, что ваш домен посчитают спамерским. Для маркетинга — специализированные сервисы.

Итоги

  • MailApp — только отправка и узкие права; GmailApp — чтение и черновики, но пугающее окно согласия. По умолчанию берите MailApp.
  • Mail merge = шаблон с {{плейсхолдерами}} + строки таблицы + String.replace с функцией-подстановкой.
  • Читайте данные через getDisplayValues(), если они пойдут прямо в текст письма.
  • Отмечайте отправленные строки немедленно и пропускайте их на входе — это делает скрипт безопасным для повторного запуска.
  • Квота считается по получателям: 100 в сутки для @gmail.com, 1500 для Workspace. Проверяйте остаток через getRemainingDailyQuota().
  • Вложения и инлайн-картинки передаются как Blob: из Диска (getAs, getBlob) или из памяти (Utilities.newBlob).
Проверьте себя
1. Почему статус «отправлено» записывают в лист сразу после каждого письма, а не одним setValues в конце цикла?
AТак быстрее — один вызов setValue дешевле, чем setValues
BЕсли скрипт упадёт или упрётся в лимит времени, при повторном запуске уже отправленные письма уйдут второй раз
CGoogle запрещает записывать несколько ячеек за один вызов после отправки почты
DИначе Gmail не сохранит письмо в папке «Отправленные»
2. Сколько единиц суточной квоты израсходует одно письмо с тремя адресами в to и одним в cc?
A1 — квота считается по письмам
B3 — cc не учитывается
C4 — квота считается по получателям
D0 — квота тратится только на внешние адреса