How to send small email campaigns from Google Sheets and Gmail without creating chaos
A small email campaign does not have to mean a separate CRM, a paid sequencing tool, and a full outbound machine. If you want to send a few well-prepared messages per day and keep contacts in Google Sheets, this can be done in a simpler way and without creating operational mess.
This guide shows a simple operating model: a sheet with clearly named columns, a menu in Apps Script, and two separate working modes. One is for testing without sending. The other is for real sending only after you deliberately remove the DRY_RUN safeguard.
This is not a spam recipe. It is a way to run a small, manually supervised campaign where you know who you are writing to, why you are contacting them, and when the contact should stop.
When this setup makes sense
This setup makes sense when you contact a small number of companies or people, you have a specific reason to reach out, and you do not want to build a separate sales system right away. It works well for early offer tests, short lead lists, and small sending batches.
If you plan hundreds or thousands of messages, multiple sending domains, complex follow-up sequences, and automated scoring, that is a different class of tool. Here the goal is control, simplicity, and small scale, not an outbound factory.
What the spreadsheet looks like
One record equals one contact. A row contains the company, the person, the email address, the reason for contact, the status, and the operational fields needed to control sending. There is no room here for guessing what a column means.
| Column | Purpose |
|---|---|
| Company | The company you are writing to. |
| First name | First name of the contact person, if you know it. |
| Recipient email address. | |
| Source | Where the contact came from or where the company was checked. |
| Reason for contact | A concrete reason why this contact makes sense. |
| Status | For example READY_TO_SEND, SENT, or HOLD. |
| Sent date | When the message actually went out. |
| Follow-up after | Date or deadline for the next move. |
| Reply | Short note about the recipient’s response. |
| Do not contact | Opt-out flag. If someone does not want contact, mark it here. |
| Subject | Optional subject per record. |
| Template | Optional message body per record. |
| Last error | Place for a validation or sending error message. |
Step 1. Prepare the sheet first, then open Apps Script
First create the Google Sheet and give it a sensible name. Only then go to Extensions → Apps Script. That order matters, because the script is supposed to work on a specific sheet, not in a vacuum.
Step 2. Paste the code, save the project, and run sheet setup
After opening the Apps Script editor, paste the full code into Code.gs, save the project, and run setupColdMailSheet. This function creates headers, a sample row, status validation, and a separate log tab.
Important
After pasting the code, do not start with “Send”. First save the project, run setupColdMailSheet, check the headers in the sheet, and only then move to the test run.
const CONFIG = {
SHEET_NAME: 'Kampania',
LOG_SHEET_NAME: 'Log',
DRY_RUN: true,
MAX_SEND_PER_RUN: 5,
SENDER_NAME: 'MorenaTech',
DEFAULT_SUBJECT: 'Krótka wiadomość w sprawie {{firma}}',
DEFAULT_TEMPLATE: [
'Cześć {{imie}},',
'',
'piszę, bo widzę konkretny powód kontaktu: {{powod}}.',
'',
'Pomagam małym firmom porządkować arkusze, maile i powtarzalne procesy w Google Workspace, Google Sheets i Apps Script.',
'',
'Jeśli temat jest nietrafiony, odpisz proszę „nie kontaktować”, a oznaczę to po swojej stronie.',
'',
'Pozdrawiam,',
'Michał',
].join('\n'),
};
const HEADERS = [
'Firma',
'Imię',
'Email',
'Źródło',
'Powód kontaktu',
'Status',
'Data wysyłki',
'Follow-up po',
'Odpowiedź',
'Nie kontaktować',
'Temat',
'Szablon',
'Ostatni błąd',
];
const STATUS = {
READY: 'DO_WYSŁANIA',
SENT: 'WYSŁANE',
HOLD: 'WSTRZYMANE',
};
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Cold mailing')
.addItem('1. Setup arkusza', 'setupColdMailSheet')
.addItem('2. Test bez wysyłki', 'testColdMailBatch')
.addSeparator()
.addItem('3. Wysyłka', 'sendColdMailBatch')
.addToUi();
}
function setupColdMailSheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(CONFIG.SHEET_NAME);
}
sheet.clear();
sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
sheet.setFrozenRows(1);
const statusColumn = HEADERS.indexOf('Status') + 1;
const dropdownRule = SpreadsheetApp.newDataValidation()
.requireValueInList([STATUS.READY, STATUS.SENT, STATUS.HOLD], true)
.setAllowInvalid(false)
.build();
sheet.getRange(2, statusColumn, Math.max(sheet.getMaxRows() - 1, 1), 1).setDataValidation(dropdownRule);
const sampleRow = [
'Przykładowa firma',
'Anno',
'anna@example.com',
'Ręcznie sprawdzona strona firmy',
'Firma ma ręczny formularz i może tracić zapytania w mailach',
STATUS.READY,
'',
'',
'',
'',
'',
'',
'',
];
sheet.getRange(2, 1, 1, sampleRow.length).setValues([sampleRow]);
sheet.autoResizeColumns(1, HEADERS.length);
ensureLogSheet_(ss);
SpreadsheetApp.getUi().alert(
'Arkusz gotowy',
'Utworzyłam zakładkę „Kampania”, nagłówki i przykładowy wiersz. Teraz możesz wkleić własne rekordy.',
SpreadsheetApp.getUi().ButtonSet.OK,
);
}
function testColdMailBatch() {
runBatch_({ forceDryRun: true });
}
function sendColdMailBatch() {
if (CONFIG.DRY_RUN) {
SpreadsheetApp.getUi().alert(
'DRY_RUN jest włączony',
'W konfiguracji masz DRY_RUN: true, więc funkcja „Wysyłka” nie wyśle prawdziwych maili. Jeśli testy są gotowe, zmień w kodzie DRY_RUN na false, zapisz projekt i uruchom „Wysyłka” ponownie.',
SpreadsheetApp.getUi().ButtonSet.OK,
);
return;
}
const ui = SpreadsheetApp.getUi();
const response = ui.alert(
'Prawdziwa wysyłka',
\`To wyśle prawdziwe maile do rekordów ze statusem \${STATUS.READY}. Limit tej partii: \${CONFIG.MAX_SEND_PER_RUN}. Kontynuować?\`,
ui.ButtonSet.YES_NO,
);
if (response !== ui.Button.YES) {
return;
}
runBatch_({ forceDryRun: false });
}
function runBatch_({ forceDryRun }) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sheet) {
throw new Error('Brak zakładki Kampania. Uruchom setupColdMailSheet.');
}
const rows = sheet.getDataRange().getValues();
if (rows.length < 2) {
SpreadsheetApp.getUi().alert('Brak rekordów do przetworzenia.');
return;
}
const remainingQuota = MailApp.getRemainingDailyQuota();
const batchLimit = Math.min(CONFIG.MAX_SEND_PER_RUN, remainingQuota);
if (batchLimit <= 0) {
SpreadsheetApp.getUi().alert('Dzisiejszy limit Gmaila jest już wykorzystany.');
return;
}
const headerMap = Object.fromEntries(
rows[0].map((name, index) => [name, index]),
);
const processed = [];
for (let rowIndex = 1; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex];
if (processed.length >= batchLimit) break;
const status = String(row[headerMap.Status] || '').trim();
const doNotContact = String(row[headerMap['Nie kontaktować']] || '').trim().toLowerCase();
if (status !== STATUS.READY || ['tak', 'true', '1'].includes(doNotContact)) {
continue;
}
const payload = {
firma: String(row[headerMap.Firma] || '').trim(),
imie: String(row[headerMap['Imię']] || '').trim(),
email: String(row[headerMap.Email] || '').trim(),
powod: String(row[headerMap['Powód kontaktu']] || '').trim(),
temat: String(row[headerMap.Temat] || '').trim() || CONFIG.DEFAULT_SUBJECT,
szablon: String(row[headerMap.Szablon] || '').trim() || CONFIG.DEFAULT_TEMPLATE,
zrodlo: String(row[headerMap['Źródło']] || '').trim(),
};
try {
validateRow_(payload);
const subject = renderTemplate_(payload.temat, payload);
const body = renderTemplate_(payload.szablon, payload);
if (!forceDryRun) {
GmailApp.sendEmail(payload.email, subject, body, {
name: CONFIG.SENDER_NAME,
replyTo: 'info@morenatech.work',
});
}
const now = new Date();
sheet.getRange(rowIndex + 1, headerMap.Status + 1).setValue(forceDryRun ? 'TEST_OK' : STATUS.SENT);
if (!forceDryRun) {
sheet.getRange(rowIndex + 1, headerMap['Data wysyłki'] + 1).setValue(now);
}
sheet.getRange(rowIndex + 1, headerMap['Ostatni błąd'] + 1).clearContent();
appendLog_(ss, {
email: payload.email,
subject,
mode: forceDryRun ? 'DRY_RUN' : 'SEND',
status: 'OK',
});
processed.push(payload.email);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sheet.getRange(rowIndex + 1, headerMap['Ostatni błąd'] + 1).setValue(message);
appendLog_(ss, {
email: payload.email,
subject: payload.temat,
mode: forceDryRun ? 'DRY_RUN' : 'SEND',
status: message,
});
}
}
SpreadsheetApp.getUi().alert(
forceDryRun ? 'Test zakończony' : 'Partia zakończona',
\`Przetworzono \${processed.length} rekord(ów). Pozostały limit Gmaila: \${remainingQuota - Math.min(processed.length, remainingQuota)}.\`,
SpreadsheetApp.getUi().ButtonSet.OK,
);
}
function ensureLogSheet_(ss) {
let logSheet = ss.getSheetByName(CONFIG.LOG_SHEET_NAME);
if (!logSheet) {
logSheet = ss.insertSheet(CONFIG.LOG_SHEET_NAME);
logSheet.appendRow(['Timestamp', 'Email', 'Subject', 'Mode', 'Status']);
}
return logSheet;
}
function appendLog_(ss, entry) {
const logSheet = ensureLogSheet_(ss);
logSheet.appendRow([new Date(), entry.email, entry.subject, entry.mode, entry.status]);
}
function renderTemplate_(template, payload) {
return template
.replaceAll('{{firma}}', payload.firma || 'Twoja firma')
.replaceAll('{{imie}}', payload.imie || 'Cześć')
.replaceAll('{{powod}}', payload.powod || 'widzę powód do kontaktu');
}
function validateRow_(payload) {
if (!payload.email) throw new Error('Brak adresu e-mail.');
if (!payload.firma) throw new Error('Brak nazwy firmy.');
if (!payload.powod) throw new Error('Brak pola „Powód kontaktu”.');
}
How test mode and send mode work
This script has two separate modes, and that separation matters operationally:
- "Test without sending" always behaves like a DRY RUN and never sends real emails.
- "Send" will send real messages only if CONFIG.DRY_RUN === false.
- Before real sending, the script shows an extra confirmation dialog.
Step 3. Run the test without sending first
The testColdMailBatch function goes through records, validates them, renders the subject and body, writes logs, but does not call real Gmail. This is the safe place to catch an empty email, a missing company name, or a bad status.
Even if you leave DRY_RUN: true in the code, the normal “Send” option will still not release real emails. You will see a warning and the script will stop.
Step 4. Only then do the real send
Once the tests are done, change CONFIG.DRY_RUN to false, save the project, and only then run 3. Send from the menu.
The script will show an extra confirmation telling you that it will send real emails to records with status DO_WYSŁANIA and that the batch has its own limit.
Why the code has a batch limit and Gmail quota check
The script is intentionally limited. You set MAX_SEND_PER_RUN, and on top of that it checks MailApp.getRemainingDailyQuota(). The point is to avoid turning one click into bulk sending and to avoid chaos when the Gmail quota is already close to the limit.
Small batches are also more sensible operationally. It is easier to review replies, stop a problematic message pattern, and clean up data after the first reactions.
Good practices before the first send
Worth checking before launch
- Do not use purchased contact lists.
- In the "Reason for contact" field, write a concrete reason, not an empty slogan.
- Protect domain reputation and do not start with large batches.
- Configure SPF, DKIM, and DMARC for the sending domain.
- If someone does not want contact, mark it in the "Do not contact" column and respect it.
- Send a few controlled tests to your own inboxes first.
This is not legal advice or a compliance checklist. It is the operational layer: list quality, reason for contact, volume control, and order on your side.
The most common mistakes in this setup
- Running "Send" before a test run and before checking the content.
- Leaving unclear statuses instead of one controlled vocabulary.
- Mixing input data with technical fields and logs.
- No simple opt-out field in the sheet.
- Starting with too large a batch and not reacting to replies.
Frequently asked questions
Can "Test without sending" send a real email?
What blocks real sending?
Can you add follow-up and more statuses?
Does this replace a CRM?
Summary
A small cold mailing workflow in Google Sheets and Gmail makes sense when you keep the scale small, have a clear reason for contact, and want to build a simple supervised process first. You do not need another SaaS immediately if a few sensible steps in Google Workspace are enough.
If you want to do this properly, three things matter most: a clean sheet, a clear split between test mode and real sending, and batch limits. The rest is data discipline, domain reputation, and quick reaction to replies.
If you need help with this setup or want to turn it into a more stable process with logs, statuses, and no manual copy-pasting, see Google Workspace and Apps Script automation.
Need to organize mailing, spreadsheets, or a simple Google Workspace workflow?
If a similar process needs to run stably, with status control, logs, and no manual copy-pasting, I can help structure it in Google Sheets, Gmail, and Apps Script.
Read more in For small business
Jak połączyć przelewy, faktury i wysyłkę bez ręcznego sprawdzania płatności
Jak uporządkować proces od zamówienia do wysyłki: sprawdzanie przelewów, status faktury, etykieta kurierska, mail do klienta i ręczna weryfikacja wyjątków.
Od czego zacząć automatyzację w małej firmie? 7 procesów na dobry start
Nie wiesz, co automatyzować najpierw? Zobacz 7 procesów, od których mała firma powinna zacząć automatyzację, zanim pomyśli o bardziej złożonych wdrożeniach AI.
Automatyzacja faktur i płatności w małej firmie
Jak uporządkować przypomnienia, statusy płatności, raporty należności i obieg informacji wokół faktur bez wdrażania dużego systemu.