Google WorkspaceDla małej firmy

Jak wysyłać małe kampanie mailowe z Google Sheets i Gmaila bez robienia bałaganu

MorenaTechMała firma testująca mały cold mailing z Arkusza GooglePraktycznyok. 11 minut
Publikacja:

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.

Arkusz Google z zakładką Kampania, nagłówkami i przykładowym rekordem do małej kampanii mailowej.Kliknij, aby powiększyć.
KolumnaPo co jest
FirmaNazwa firmy, do której piszesz.
ImięImię osoby kontaktowej, jeśli je znasz.
EmailAdres odbiorcy.
ŹródłoSkąd masz kontakt lub gdzie sprawdziłaś firmę.
Powód kontaktuKonkretny powód, dlaczego ten kontakt ma sens.
StatusNa przykład DO_WYSŁANIA, WYSŁANE albo WSTRZYMANE.
Data wysyłkiKiedy wiadomość realnie wyszła.
Follow-up poData 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.
TematOpcjonalny temat per rekord.
SzablonOpcjonalna treść per rekord.
Ostatni błądMiejsce 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.

Wejście do Apps Script z poziomu arkusza Google: Extensions → Apps Script.Kliknij, aby powiększyć.

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.

Edytor Apps Script z funkcjami onOpen, setupColdMailSheet, testColdMailBatch i sendColdMailBatch.Kliknij, aby powiększyć.

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.

Jeśli DRY_RUN jest włączony, funkcja „Wysyłka” kończy się komunikatem i nie wysyła prawdziwych maili.Kliknij, aby powiększyć.

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.

Dodatkowe potwierdzenie przed prawdziwą wysyłką do rekordów ze statusem DO_WYSŁANIA.Kliknij, aby powiększyć.

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?

Nie. Ta funkcja uruchamia batch w trybie wymuszonym forceDryRun, więc waliduje rekordy i zapisuje logi, ale nie wywołuje GmailApp.sendEmail.

Co blokuje prawdziwą wysyłkę?

Dwie rzeczy. Po pierwsze CONFIG.DRY_RUN musi mieć wartość false. Po drugie użytkownik musi jeszcze raz potwierdzić wysyłkę w oknie dialogowym.

Czy można dorobić follow-up i więcej statusów?

Tak, ale warto zacząć od prostego modelu. Najpierw opanuj jeden kontakt, jeden status startowy i jeden log. Rozbudowa ma sens dopiero wtedy, gdy podstawowy przepływ działa stabilnie.

Czy to zastępuje CRM?

Nie. To jest kontrolowany workflow do małej kampanii z arkusza, a nie pełny CRM, sekwencje sprzedażowe i zarządzanie pipeline na dużą skalę.

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