Jak wysyłać małe kampanie mailowe z Google Sheets i Gmaila bez robienia bałaganu
Mała kampania mailowa nie musi od razu oznaczać osobnego CRM-a, płatnego narzędzia do sekwencji i wielkiej maszyny do outboundu. Jeśli chcesz wysłać kilka dobrze przygotowanych wiadomości dziennie, a kontakty trzymasz w Google Sheets, da się to zrobić prościej i bez robienia bałaganu.
Ten poradnik pokazuje prosty model działania: arkusz z jasno nazwanymi kolumnami, menu w Apps Script i dwa osobne tryby pracy. Jeden służy do testu bez wysyłki. Drugi do prawdziwej wysyłki tylko wtedy, gdy świadomie zdejmiesz blokadę DRY_RUN.
To nie jest przepis na spam. To jest sposób na małą, ręcznie nadzorowaną kampanię, w której wiesz, komu piszesz, z jakiego powodu i kiedy trzeba zatrzymać kontakt.
Kiedy taki układ ma sens
Taki arkusz ma sens wtedy, gdy kontaktujesz się z niewielką liczbą firm lub osób, masz konkretny powód kontaktu i nie chcesz od razu budować osobnego systemu sprzedażowego. Dobrze działa przy pierwszych testach oferty, krótkich listach leadów i małych partiach wysyłki.
Jeśli planujesz setki lub tysiące wiadomości, wiele domen nadawczych, rozbudowane sekwencje follow-up i automatyczne scoringi, to jest inna klasa narzędzia. Tu celem jest kontrola, prostota i mały zasięg, a nie hurtownia outboundu.
Jak wygląda arkusz
Jeden rekord to jeden kontakt. Wiersz zawiera firmę, osobę, adres e-mail, powód kontaktu, status i pola operacyjne potrzebne do pilnowania wysyłki. Nie ma tu miejsca na zgadywanie, co oznacza dana kolumna.
| Kolumna | Po co jest |
|---|---|
| Firma | Nazwa firmy, do której piszesz. |
| Imię | Imię osoby kontaktowej, jeśli je znasz. |
| Adres odbiorcy. | |
| Źródło | Skąd masz kontakt lub gdzie sprawdziłaś firmę. |
| Powód kontaktu | Konkretny powód, dlaczego ten kontakt ma sens. |
| Status | Na przykład DO_WYSŁANIA, WYSŁANE albo WSTRZYMANE. |
| Data wysyłki | Kiedy wiadomość realnie wyszła. |
| Follow-up po | Data lub termin kolejnego ruchu. |
| Odpowiedź | Krótka notatka o reakcji odbiorcy. |
| Nie kontaktować | Flaga opt-out. Jeśli ktoś nie chce kontaktu, zaznaczasz to tutaj. |
| Temat | Opcjonalny temat per rekord. |
| Szablon | Opcjonalna treść per rekord. |
| Ostatni błąd | Miejsce na komunikat, jeśli dany wiersz nie przeszedł walidacji lub wysyłki. |
Krok 1. Przygotuj arkusz i dopiero potem wejdź do Apps Script
Najpierw utwórz arkusz Google i nadaj mu sensowną nazwę. Dopiero potem przejdź do menu Extensions → Apps Script. To ważna kolejność, bo skrypt ma pracować na konkretnym arkuszu, a nie w próżni.
Krok 2. Wklej kod, zapisz projekt i uruchom setup arkusza
Po otwarciu edytora Apps Script wklej cały kod do pliku Code.gs, zapisz projekt i uruchom funkcję setupColdMailSheet. Ta funkcja tworzy nagłówki, przykładowy wiersz, walidację statusu i osobną zakładkę logu.
Ważne
Po wklejeniu kodu nie zaczynaj od „Wysyłka”. Najpierw zapisz projekt, uruchom setupColdMailSheet, sprawdź nagłówki w arkuszu i dopiero potem przejdź do testu.
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',
'[email protected]',
'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: '[email protected]',
});
}
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”.');
}
Jak działają tryby testu i wysyłki
W tym skrypcie są dwa osobne tryby pracy i to rozdzielenie ma znaczenie operacyjne:
- „Test bez wysyłki” zawsze działa jak DRY RUN i nie wysyła prawdziwych maili.
- „Wysyłka” wyśle prawdziwe wiadomości tylko wtedy, gdy CONFIG.DRY_RUN === false.
- Przed prawdziwą wysyłką skrypt pokazuje drugie okno potwierdzenia.
Krok 3. Najpierw test bez wysyłki
Funkcja testColdMailBatch przechodzi przez rekordy, sprawdza walidację, renderuje temat i treść, zapisuje logi, ale nie wywołuje prawdziwego Gmaila. To jest bezpieczne miejsce na złapanie pustego e-maila, braku nazwy firmy albo źle ustawionego statusu.
Nawet jeśli w kodzie zostawisz DRY_RUN: true, zwykła opcja „Wysyłka” też nie wypuści maili. Zobaczysz wtedy komunikat ostrzegawczy i skrypt się zatrzyma.
Krok 4. Dopiero potem prawdziwa wysyłka
Gdy testy są gotowe, zmieniasz w kodzie CONFIG.DRY_RUNna false, zapisujesz projekt i dopiero wtedy uruchamiasz z menu pozycję 3. Wysyłka.
Skrypt pokaże dodatkowe potwierdzenie z informacją, że wyśle prawdziwe maile do rekordów ze statusem DO_WYSŁANIA i że partia ma własny limit.
Dlaczego w kodzie jest limit partii i quota Gmaila
Skrypt celowo nie działa bez ograniczeń. Ustawiasz MAX_SEND_PER_RUN, a dodatkowo sprawdzana jest wartość z MailApp.getRemainingDailyQuota(). Chodzi o to, żeby nie robić z jednego kliknięcia hurtowej wysyłki i nie wpaść w chaos, gdy limit Gmaila jest już blisko końca.
Małe partie są rozsądniejsze także operacyjnie. Łatwiej sprawdzić odpowiedzi, zatrzymać problematyczny wzór wiadomości i poprawić dane po pierwszych reakcjach.
Dobre praktyki przed pierwszą wysyłką
To warto sprawdzić przed startem
- Nie korzystaj z kupionych list kontaktów.
- W polu „Powód kontaktu” wpisuj konkretny powód, a nie pusty slogan.
- Pilnuj reputacji domeny i nie zaczynaj od dużych partii.
- Skonfiguruj SPF, DKIM i DMARC dla domeny, z której wysyłasz.
- Jeśli ktoś nie chce kontaktu, oznacz to w kolumnie „Nie kontaktować” i respektuj ten sygnał.
- Najpierw wyślij kilka własnych testów na kontrolowane skrzynki.
To nie jest porada prawna ani compliance checklist. To jest praktyczna warstwa operacyjna: jakość listy, sens kontaktu, kontrola wolumenu i porządek po swojej stronie.
Najczęstsze błędy w takim setupie
- Uruchomienie „Wysyłka” bez wcześniejszego testu i bez sprawdzenia treści.
- Zostawienie niejasnych statusów zamiast jednego, kontrolowanego słownika.
- Mieszanie danych wejściowych z polami technicznymi i logami.
- Brak prostego opt-out w arkuszu.
- Za duża partia na start i brak reakcji na odpowiedzi odbiorców.
Najczęstsze pytania
Czy „Test bez wysyłki” może wysłać prawdziwy e-mail?
Co blokuje prawdziwą wysyłkę?
Czy można dorobić follow-up i więcej statusów?
Czy to zastępuje CRM?
Podsumowanie
Mały cold mailing z Google Sheets i Gmaila ma sens wtedy, gdy trzymasz małą skalę, masz konkretny powód kontaktu i chcesz najpierw zbudować prosty, nadzorowany proces. Nie trzeba od razu dokładania kolejnego SaaS-a, jeśli kilka rozsądnych kroków w Google Workspace wystarczy.
Jeśli chcesz to zrobić porządnie, krytyczne są trzy rzeczy: czytelny arkusz, rozdzielenie testu od prawdziwej wysyłki i ograniczenie partii. Reszta to dyscyplina danych, reputacja domeny i szybkie reagowanie na odpowiedzi.
Jeśli potrzebujesz pomocy przy takim układzie albo chcesz z niego zrobić bardziej stabilny proces z logami, statusem i bez ręcznego przeklejania danych, zobacz stronę automatyzacje Google Workspace i Apps Script.
Chcesz uporządkować mailing, arkusze albo prosty workflow w Google Workspace?
Jeśli podobny proces ma działać stabilnie, z kontrolą statusów, logami i bez ręcznego przeklejania danych, mogę pomóc to poukładać w Google Sheets, Gmailu i Apps Script.
Czytaj dalej w sekcji Dla małej firmy
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.
Automatyczne przypomnienia dla klientów i zespołu: przykłady dla małej firmy
Jak zaplanować automatyczne przypomnienia o płatnościach, zadaniach, brakujących danych i terminach bez zamieniania firmy w fabrykę powiadomień.