Отправка писем через Gmail
Как разослать двести персональных писем прямо из таблицы, ни разу не открыв Gmail руками — и не отправить половину людей дважды.
MailApp — служба Apps Script, которая умеет только одно: отправлять письма от вашего имени. GmailApp — её старшая сестра: она отправляет письма и вдобавок читает почту, работает с черновиками, ярлыками и цепочками.
Зачем это нужно
Практически любая офисная рутина рано или поздно упирается в письмо. Бухгалтер каждый месяц рассылает сотрудникам расчётные листки. Менеджер напоминает клиентам о задолженности. Организатор шлёт участникам конференции индивидуальные ссылки на подключение. Во всех этих случаях исходные данные уже лежат в таблице — фамилии, адреса, суммы, сроки, — а человек тратит два часа на то, чтобы механически перенести их в почту.
Такая рассылка называется mail merge — «слияние писем»: берём шаблон с пропусками и подставляем в него значения из каждой строки. Apps Script делает это за секунды, потому что таблица и почта живут в одном аккаунте Google и скрипту не нужны ни пароли, ни SMTP-настройки, ни сторонние сервисы.
MailApp или GmailApp — что выбрать
Обе службы умеют отправлять письма, и синтаксис у них почти одинаковый. Разница — в правах доступа и в побочных эффектах.
| Критерий | MailApp | GmailApp |
| Что умеет | только отправлять | отправлять, читать, искать, работать с черновиками и ярлыками |
| Какие права запросит у пользователя | узкие: «отправлять письма от вашего имени» | широкие: «читать, изменять, отправлять и удалять почту» |
| Письмо попадает в «Отправленные» | обычно нет | да |
| Когда брать | рассылки, уведомления, отчёты | когда нужно разбирать входящие или готовить черновики на проверку |
Правило простое: если задача — «просто отправить», берите 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.com | 100 |
| 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).