Email Yourself a Daily Google Sheets Summary, Automatically

To email yourself a daily summary of a Google Sheet, you need an installable time-driven trigger, not the onEdit() simple trigger most tutorials start with — onEdit() only fires when someone is actively editing the sheet, so it can’t run on a schedule or send mail without extra authorization. Below is a complete Apps Script that reads a sheet, summarizes the rows added in the last 24 hours, and emails the result — verified in a Node.js sandbox line by line, with the parts that couldn’t be verified clearly marked.

Which trigger do you actually need?

Google Sheets automation gets confusing because there are several trigger types that all sound similar. Here’s how they differ:

You want to… Trigger type Fires when
Get a summary at a fixed time each day Time-driven (this article) On a schedule you set, whether or not anyone opens the sheet
Get notified the moment a specific cell changes Installable onEdit Immediately after an edit — see our guide to cell-change email triggers
Route each new Form response to a different tab Installable form-submit The instant a form is submitted — see routing Form responses to different sheets
Get a plain daily/weekly digest without writing code Sheets’ built-in “Notification rules” Native feature, no Apps Script needed, but can’t summarize or aggregate — it just forwards raw change notifications

If what you actually want is “notify me the instant something changes,” you don’t need this article — the edit-trigger guide linked above is simpler and fires immediately. Time-driven triggers are for when you want a digest: one email, once a day, regardless of how many changes happened.

The verified script

This reads a sheet with Timestamp, Category, and Amount columns (rename the fields in CONFIG to match your sheet), keeps only rows from the last 24 hours, and emails a count + total + category breakdown. Column order doesn’t matter — it looks up values by header name, not position.

/* ===== CONFIG — edit these ===== */
const CONFIG = {
  sheetName: 'Expenses',
  timestampField: 'Timestamp',
  amountField: 'Amount',
  categoryField: 'Category',
  recipient: 'me@example.com',
};

/* ===== One-time setup: run this once to install the trigger ===== */
function createDailyTrigger() {
  // Delete any existing trigger for this function first — Apps Script has no
  // "update trigger" API, only delete + recreate.
  ScriptApp.getProjectTriggers().forEach(t => {
    if (t.getHandlerFunction() === 'sendDailySummaryEmail') {
      ScriptApp.deleteTrigger(t);
    }
  });
  ScriptApp.newTrigger('sendDailySummaryEmail')
    .timeBased()
    .atHour(9)
    .everyDays(1)
    .create();
}

/* ===== The function the trigger calls every day ===== */
function sendDailySummaryEmail() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.sheetName);
  const values = sheet.getDataRange().getValues();
  const rows = rowsToObjects(values);

  const now = new Date();
  const sinceDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
  const { kept, skippedUnparseable } = filterRowsSince(rows, sinceDate, CONFIG.timestampField);
  const summary = summarizeRows(kept, { amountField: CONFIG.amountField, categoryField: CONFIG.categoryField });
  const html = buildSummaryEmailHtml(summary, {
    sheetName: CONFIG.sheetName,
    dateLabel: now.toDateString(),
    amountLabel: CONFIG.amountField,
    skippedUnparseable,
  });

  MailApp.sendEmail({
    to: CONFIG.recipient,
    subject: `${CONFIG.sheetName} daily summary — ${now.toDateString()}`,
    htmlBody: html,
  });
}

/* ===== Pure helpers (verified separately in Node — see article) ===== */
function rowsToObjects(values) {
  if (!values || values.length < 1) return [];
  const headers = values[0].map(h => String(h).trim());
  const out = [];
  for (let i = 1; i < values.length; i++) {
    const row = values[i];
    const obj = {};
    headers.forEach((h, idx) => { obj[h] = row[idx]; });
    out.push(obj);
  }
  return out;
}

function parseCellDate(value) {
  if (value instanceof Date) {
    return isNaN(value.getTime()) ? null : value;
  }
  if (typeof value === 'string' && value.trim() !== '') {
    const d = new Date(value);
    return isNaN(d.getTime()) ? null : d;
  }
  return null;
}

function filterRowsSince(rows, sinceDate, timestampField) {
  const kept = [];
  let skippedUnparseable = 0;
  for (const row of rows) {
    const d = parseCellDate(row[timestampField]);
    if (d === null) { skippedUnparseable++; continue; }
    if (d.getTime() >= sinceDate.getTime()) kept.push(row);
  }
  return { kept, skippedUnparseable };
}

function summarizeRows(rows, opts) {
  const { amountField, categoryField } = opts;
  let total = 0;
  let amountSum = 0;
  let amountSkipped = 0;
  const byCategory = {};

  for (const row of rows) {
    total++;
    const rawAmount = row[amountField];
    const num = typeof rawAmount === 'number' ? rawAmount : parseFloat(rawAmount);
    if (rawAmount === '' || rawAmount === undefined || rawAmount === null || isNaN(num)) {
      amountSkipped++;
    } else {
      amountSum += num;
    }
    let cat = row[categoryField];
    cat = (cat === undefined || cat === null || String(cat).trim() === '') ? 'Uncategorized' : String(cat).trim();
    byCategory[cat] = (byCategory[cat] || 0) + 1;
  }

  return { total, amountSum, amountSkipped, byCategory };
}

function buildSummaryEmailHtml(summary, opts) {
  const { sheetName, dateLabel, amountLabel, skippedUnparseable } = opts;
  let html = `<h2>${escapeHtml(sheetName)} — daily summary for ${escapeHtml(dateLabel)}</h2>`;

  if (summary.total === 0) {
    html += `<p>No new rows since the last run. The trigger is working — there's just nothing to report today.</p>`;
    return html;
  }

  html += `<p><strong>${summary.total}</strong> new row(s).</p>`;
  html += `<p><strong>${amountLabel} total:</strong> ${summary.amountSum.toFixed(2)}`;
  if (summary.amountSkipped > 0) {
    html += ` <span style="color:#888">(${summary.amountSkipped} row(s) had no valid number and were excluded from this total)</span>`;
  }
  html += `</p>`;

  html += `<table border="1" cellpadding="6" cellspacing="0"><tr><th>Category</th><th>Count</th></tr>`;
  Object.keys(summary.byCategory).sort().forEach(cat => {
    html += `<tr><td>${escapeHtml(cat)}</td><td>${summary.byCategory[cat]}</td></tr>`;
  });
  html += `</table>`;

  if (skippedUnparseable > 0) {
    html += `<p style="color:#888">${skippedUnparseable} row(s) had a timestamp that couldn't be read and were left out of this summary.</p>`;
  }

  return html;
}

function escapeHtml(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

To install: open your sheet, go to Extensions > Apps Script, delete the placeholder code, paste the script above, update the five values in CONFIG, save, then select createDailyTrigger from the function dropdown and click Run once. Google will prompt you to authorize the script (it needs access to the sheet and to send mail as you) — approve it, and the trigger is live. You only run createDailyTrigger once; sendDailySummaryEmail is what runs automatically after that.

Why “last 24 hours,” not “today”

The script compares each row’s timestamp to now - 24 hours, not midnight-to-midnight. That’s a deliberate choice: if your trigger fires at 9 AM, a “today” window would miss anything added between midnight and 9 AM the same day and double-count nothing added yet. A rolling 24-hour window always covers exactly one full day of activity relative to when the email actually goes out, which matters more once you read the next section.

Your 9 AM trigger won’t fire at exactly 9 AM

This is the part most tutorials skip. Google’s own installable triggers documentation states it plainly: for a recurring trigger set to a given hour, “Apps Script chooses a time between [that hour] and [the next hour], then keeps that timing consistent from day to day so that 24 hours elapse before the trigger fires again.” Set .atHour(9) and your email might land at 9:04 AM one day and 9:41 AM the next — but whichever minute it picks, it’ll repeat at that same minute every day after. This is normal, not a bug, and it’s exactly why the script uses a rolling 24-hour window instead of a calendar-day boundary — the two are designed to work together.

Errors you’ll hit

Error message What it means Fix
Authorization is required to perform that action. The trigger tried to run before you approved the permissions prompt, or approval was revoked Open the script editor, run createDailyTrigger manually once, and click through the OAuth consent screen
Service invoked too many times for one day: email. You’ve hit the MailApp daily recipient quota Check MailApp.getRemainingDailyQuota(); wait for the 24-hour reset or move to a Google Workspace account for a higher limit (see quota table below)
Exceeded maximum execution time The function ran longer than 6 minutes — usually means the sheet is very large or you’re doing per-row API calls in a loop Read the whole range with one getValues() call (the script above already does this) instead of calling getRange() per row
Trigger silently stops running after a while, no error visible in the editor Apps Script auto-disables a trigger after too many consecutive failed runs and emails you a “Summary of failures” notice Check the email account tied to the script for the failure summary, fix the underlying cause, then re-run createDailyTrigger
Edits to the script don’t seem to take effect There’s no “update trigger” API — an existing trigger keeps calling the function by name, so code changes inside that function DO apply, but changes to the trigger’s schedule don’t To change the time, delete the old trigger and create a new one — createDailyTrigger above already does this delete-then-create step for you

Quotas that apply (verified today)

Pulled directly from Google’s Apps Script quotas documentation, checked on the day this article was written:

Limit Consumer account (gmail.com) Google Workspace account
Triggers per user per script 20 20
Total trigger runtime per day 90 minutes 6 hours
Single script execution 6 minutes 6 minutes
Email recipients per day (MailApp) 100 1,500

A single daily summary email uses one of your 20 triggers and one recipient slot — you won’t come close to these limits unless you’re running several automations on the same script or sending to a large distribution list.

If this isn’t the automation you need

Time-driven digests aren’t always the right tool — if you need to know about a change within seconds rather than once a day, or you’re not sure which of several Sheets automations fits your task, the comparison table near the top of this article and the Sheets automation decision checklist cover the alternatives.

What was actually verified

All four helper functions (rowsToObjects, parseCellDate, filterRowsSince, summarizeRows, buildSummaryEmailHtml) were run in a plain Node.js sandbox against 19 unit-test assertions covering: header-based column mapping, reordered columns, blank/malformed timestamps, blank/non-numeric amounts, blank categories, and HTML-escaping of category names. The orchestration logic — how those functions are wired together inside sendDailySummaryEmail() — was separately verified with 5 more assertions using mock SpreadsheetApp/MailApp objects standing in for the real Google services, confirming the 24-hour window boundary, the “no activity” email path, and that column order doesn’t break the summary. The full script also passed a Node.js syntax check.

What wasn’t verified, because it requires a live Google Workspace account this site doesn’t have: the actual OAuth consent flow, whether MailApp.sendEmail delivers successfully, the real behavior of SpreadsheetApp.getActive() and getDataRange().getValues() against a live sheet, and the trigger-timing randomization described above (that’s Google’s own documented behavior, not something tested independently here). If you hit a discrepancy between what’s described here and what you see in your own sheet, the errors table above covers the most common causes.

Common questions

Why didn’t my email arrive at exactly the time I set?
Google intentionally randomizes recurring time-driven triggers within the hour you specify, then keeps that same minute every day after. See the trigger-timing section above — this is documented behavior, not a malfunction.

Can I get more than one summary email per day?
Yes — create additional triggers with .atHour() set to different hours, or use .everyHours(n) instead of .everyDays(1). Each script is limited to 20 triggers total, and total trigger runtime is capped at 90 minutes/day (consumer) or 6 hours/day (Workspace).

Why did the trigger stop working after running fine for weeks?
Apps Script automatically disables a trigger after enough consecutive failed executions and sends a “Summary of failures” email to the script owner explaining why. Check that inbox first — the fix is almost always in that message.

Leave a Comment