Google WorkspaceFor small business

How to send small email campaigns from Google Sheets and Gmail without creating chaos

MorenaTechSmall businesses testing small outbound batches from Google SheetsPracticalabout 11 min
Published:

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.

Google Sheet with the Campaign tab, headers, and a sample record for a small email campaign.Click to enlarge.
ColumnPurpose
CompanyThe company you are writing to.
First nameFirst name of the contact person, if you know it.
EmailRecipient email address.
SourceWhere the contact came from or where the company was checked.
Reason for contactA concrete reason why this contact makes sense.
StatusFor example READY_TO_SEND, SENT, or HOLD.
Sent dateWhen the message actually went out.
Follow-up afterDate or deadline for the next move.
ReplyShort note about the recipient’s response.
Do not contactOpt-out flag. If someone does not want contact, mark it here.
SubjectOptional subject per record.
TemplateOptional message body per record.
Last errorPlace 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.

Opening Apps Script from a Google Sheet: Extensions → Apps Script.Click to enlarge.

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.

Apps Script editor with onOpen, setupColdMailSheet, testColdMailBatch, and sendColdMailBatch.Click to enlarge.

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.

If DRY_RUN is enabled, the "Send" function ends with a warning and does not send real emails.Click to enlarge.

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.

Extra confirmation before real sending to records with READY_TO_SEND status.Click to enlarge.

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?

No. That function runs the batch with forced forceDryRun, so it validates records and writes logs, but it does not call GmailApp.sendEmail.

What blocks real sending?

Two things. First, CONFIG.DRY_RUN must be set to false. Second, the user must confirm the send one more time in the dialog window.

Can you add follow-up and more statuses?

Yes, but it is better to start with a simple model. First control one contact, one starting status, and one log. Expanding the flow makes sense only when the base process works stably.

Does this replace a CRM?

No. This is a controlled workflow for a small sheet-based campaign, not a full CRM, sales sequence engine, or large-scale pipeline tool.

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