Recipes / Google Workspace & Gmail

Generate a weekly report from Sheets and email it

A weekly report gets rebuilt by copying numbers out of a working sheet into a summary, then emailing it around, every week without fail.

Reporting

Updated September 2026

← All recipes
STEP01TriggerSCHEDULED TRIGGER FIRES WEEKLYSTEP02PullSHEET DATA PULLED AND SUMMARISEDINTO THE REPORT LAYOUTSTEP03EmailREPORT EMAILED TO THEDISTRIBUTION LISTREPORTINGSTEP 01TriggerSCHEDULED TRIGGERFIRES WEEKLYSTEP 02PullSHEET DATA PULLED ANDSUMMARISED INTO THEREPORT LAYOUTSTEP 03EmailREPORT EMAILED TO THEDISTRIBUTION LIST
DwgHow it runs
SheetRecipe
RevSeptember 2026

A weekly report can be generated from a working Google Sheet on a schedule and emailed to a distribution list without anyone rebuilding it, with every number tracing back to the live sheet. Evenops builds this as an Apps Script trigger, so the report layout stays fixed and the recipient list is one place to update.

How does it run?

3 steps
  1. Step 01Trigger

    Scheduled trigger fires weekly

  2. Step 02Pull

    Sheet data pulled and summarised into the report layout

  3. Step 03Email

    Report emailed to the distribution list

What do you get?

3 outcomes
  • ✓The report goes out on schedule without anyone rebuilding it
  • ✓Numbers always trace back to the live sheet
  • ✓The distribution list is one place to update, not one email per person

Which tools does it use?

Google SheetsGmailApps Script

Time saved

Removes a recurring manual reporting task.

What does the script do?

A time-driven Apps Script that, from early on Monday, takes the headline figures from a Summary tab of your report spreadsheet, puts them in the body of an email as a plain table, attaches the whole report file as a PDF, and sends it to a list of people. The trigger runs every hour, but the script does work only on the send day, from the send hour, and only until the report has gone: it records the day it sent, so later hourly runs and extra presses of Run send nothing. If the send fails, the day is not marked as sent and the next hourly run tries again, up to six times that day, and an administrator is emailed. The numbers are only as good as the sheet: the script reports what the sheet shows and does no calculation of its own.

What do you need first?

  • A Google Sheets file laid out as the report, with a tab called Summary holding the headline figures you want in the email body.
  • The email addresses of the people who should receive it, and a shared mailbox for failure alerts.
  • Permission to send as the account that will own the script. Replies go to that account, so use a shared mailbox if you can.
  • About 20 minutes. No paid add-on.

How do you set it up?

7 steps
  1. Prepare the report file

    The PDF contains every tab of the file, so keep working data in a separate file and bring only the figures you want to send into this one with formulas. Check how it looks with File, then Print, before you automate it.

  2. Create the script

    Open script.google.com while signed in to the account that should send the email and choose New project. Rename it, for example Weekly report, and paste the code below into Code.gs.

  3. Fill in CONFIG

    Paste the report file's ID (the string between /d/ and /edit in its URL) into REPORT_SPREADSHEET_ID, list the RECIPIENTS, and set ALERT_TO. Put your own address as the only recipient for the first test.

  4. Run it once by hand

    Pick sendWeeklyReport in the function dropdown and press Run. Google asks you to authorise Sheets, Drive and email access. A warning that the app is not verified is normal for a script you wrote yourself. Check the email and open the PDF.

  5. Test again if you need to

    A second run on the same day does nothing on purpose. To send again, open Project Settings, find lastSentOn under Script Properties and delete it, and set SEND_DAY to today and SEND_FROM_HOUR to 0 while you test.

  6. Install the trigger

    Add the real recipients, then pick installTrigger and press Run once. It schedules sendWeeklyReport to run every hour and replaces any earlier copy. The send day (SEND_DAY, Monday by default) and hour (SEND_FROM_HOUR, 7 by default) are read in the script's time zone, so check it under Project Settings.

  7. Turn on failure emails

    In Triggers, open the trigger and set Failure notification settings to notify you immediately. This catches the case where the script itself stops running, which the script cannot email you about.

The script

WeeklyReport.gs
// Emails a weekly report from a Google Sheet: headline figures in the body, the whole file as a PDF.
// The trigger runs hourly so a failed send is retried the same day instead of waiting a week.
// Paste into Code.gs in a new project at script.google.com, edit CONFIG, run installTrigger() once.

const CONFIG = {
  REPORT_SPREADSHEET_ID: 'PASTE_THE_SPREADSHEET_ID_HERE', // the file that holds your report
  SUMMARY_SHEET: 'Summary',          // this tab becomes the table in the email body
  RECIPIENTS: ['owner@example.com', 'team@example.com'],
  SUBJECT: 'Weekly report',
  SENDER_NAME: 'Weekly report',
  SEND_DAY: 1,                       // 1 = Monday ... 7 = Sunday
  SEND_FROM_HOUR: 7,                 // 24 hour clock, in the script's time zone
  MAX_ATTEMPTS_PER_DAY: 6,           // a failed send is retried each hour, this many times
  MAX_SUMMARY_ROWS: 40,              // longer tabs are cut off in the email body (the PDF still has everything)
  ALERT_TO: 'ops@example.com',
  ALERT_EVERY_MS: 6 * 60 * 60 * 1000, // at most one failure email per 6 hours
};

function sendWeeklyReport() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30 * 1000)) return; // another run is already sending
  const problems = [];
  let force = false;
  let sent = false;
  try {
    const props = PropertiesService.getScriptProperties();
    const zone = Session.getScriptTimeZone();
    const now = new Date();
    const today = Utilities.formatDate(now, zone, 'yyyy-MM-dd');
    // The trigger runs every hour. Only the send day, from the send hour, does any work, and only once.
    if (Number(Utilities.formatDate(now, zone, 'u')) !== CONFIG.SEND_DAY) return;
    if (Number(Utilities.formatDate(now, zone, 'HH')) < CONFIG.SEND_FROM_HOUR) return;
    if (props.getProperty('lastSentOn') === today) return;

    // A failed send is retried at the next hourly run, up to MAX_ATTEMPTS_PER_DAY times.
    const tries = props.getProperty('attemptsOn') === today ? Number(props.getProperty('attempts') || 0) : 0;
    props.setProperty('attemptsOn', today);
    props.setProperty('attempts', String(tries + 1));
    force = tries + 1 >= CONFIG.MAX_ATTEMPTS_PER_DAY; // the last attempt reports at once, throttle or not
    if (tries >= CONFIG.MAX_ATTEMPTS_PER_DAY) return;

    try {
      const ss = SpreadsheetApp.openById(CONFIG.REPORT_SPREADSHEET_ID);
      SpreadsheetApp.flush(); // apply any pending changes before the export
      const summary = ss.getSheetByName(CONFIG.SUMMARY_SHEET);
      if (!summary) throw new Error('There is no tab called ' + CONFIG.SUMMARY_SHEET);
      const rows = summary.getDataRange().getDisplayValues().slice(0, CONFIG.MAX_SUMMARY_ROWS);
      if (!rows.length || !rows[0].length) throw new Error('The ' + CONFIG.SUMMARY_SHEET + ' tab is empty');

      const pdf = ss.getAs(MimeType.PDF).setName(CONFIG.SUBJECT + ' ' + today + '.pdf');
      MailApp.sendEmail({
        to: CONFIG.RECIPIENTS.join(','),
        subject: CONFIG.SUBJECT + ' - ' + today,
        body: toText_(rows) + '\n\nFull report attached as a PDF.',
        htmlBody: toHtml_(rows) + '<p>Full report attached as a PDF.</p>',
        attachments: [pdf],
        name: CONFIG.SENDER_NAME,
      });
      props.setProperty('lastSentOn', today); // recorded only after the send worked
      sent = true;
    } catch (err) {
      problems.push('Weekly report attempt ' + (tries + 1) + ' of ' + CONFIG.MAX_ATTEMPTS_PER_DAY + ' failed: ' + err.message);
    }
  } catch (err) {
    problems.push('Weekly report run failed: ' + err.message);
  } finally {
    lock.releaseLock();
  }
  // After a send that followed failed attempts, send the held-back problems now rather than next week.
  alertOnce_(problems, force || sent);
}

// Every value from the sheet goes through esc_ before it is placed in HTML.
function toHtml_(rows) {
  const body = rows.map(function (row, i) {
    const tag = i === 0 ? 'th' : 'td';
    return '<tr>' + row.map(function (cell) {
      return '<' + tag + ' style="padding:4px 10px;border:1px solid #ddd;text-align:left">' + esc_(cell) + '</' + tag + '>';
    }).join('') + '</tr>';
  }).join('');
  return '<table style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:14px">' + body + '</table>';
}

function toText_(rows) {
  return rows.map(function (row) { return row.join(' | '); }).join('\n');
}

function esc_(value) {
  return String(value)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

// Keeps the held-back problems in a script property. Oldest entries are dropped first if the text
// would get long (a property value is limited to about 9 KB), and a failure to store never fails the run.
function saveQueue_(props, queue) {
  try {
    while (queue.length > 1 && JSON.stringify(queue).length > 6000) queue.shift();
    props.setProperty('pendingProblems', JSON.stringify(queue));
  } catch (e) { /* the problems are lost only if the store itself is down */ }
}

// Emails the problems to ALERT_TO, at most once every ALERT_EVERY_MS. Problems found while an email is
// being held back are kept in a script property and go out with the next one, so each is reported.
function alertOnce_(problems, force) {
  const props = PropertiesService.getScriptProperties();
  let queue = [];
  try { queue = JSON.parse(props.getProperty('pendingProblems') || '[]'); } catch (e) { queue = []; }
  (problems || []).forEach(function (p) {
    const text = String(p).slice(0, 160);
    if (queue.indexOf(text) === -1) queue.push(text);
  });
  queue = queue.slice(-30);
  if (!queue.length) return;
  const last = Number(props.getProperty('lastAlertAt') || 0);
  if (!force && Date.now() - last < CONFIG.ALERT_EVERY_MS) {
    saveQueue_(props, queue);
    return;
  }
  try {
    MailApp.sendEmail(CONFIG.ALERT_TO, 'Weekly report needs attention (' + queue.length + ')', queue.join('\n'));
    props.setProperty('lastAlertAt', String(Date.now()));
    props.deleteProperty('pendingProblems');
  } catch (err) {
    saveQueue_(props, queue); // keep them for the next try
  }
}

// Run once by hand. Safe to run again: it replaces the old trigger.
function installTrigger() {
  ScriptApp.getProjectTriggers()
    .filter(function (t) { return t.getHandlerFunction() === 'sendWeeklyReport'; })
    .forEach(function (t) { ScriptApp.deleteTrigger(t); });
  // Hourly. sendWeeklyReport does nothing except on the send day, from the send hour, until the report has gone.
  ScriptApp.newTrigger('sendWeeklyReport').timeBased().everyHours(1).create();
}

Every Apps Script method in this script was checked against Google's Apps Script reference (developers.google.com/apps-script/reference) on 11/10/2026. Limits come from Google's quota table, checked the same day.

What should you know before relying on it?

8 caveats
Quotas
Google's quota table lists a 6 minute limit per execution, 20 triggers per user per script, and a total trigger runtime of 90 minutes a day on a gmail.com account or 6 hours a day on Google Workspace. This trigger runs every hour, 24 runs a day, which on a gmail.com account works out to an average of about 225 seconds per run and on Workspace about 900. Almost every run leaves at once (it is not the send day, or the report has already gone), so the real use is a few seconds a day plus the one send. The quota table also lists 100 email recipients a day on gmail.com and 1,500 on Workspace (each address counts), 25 MB for attachments per message, and an email body limit of 200 KB on gmail.com and 400 KB on Workspace. A report PDF over 25 MB cannot be sent: keep the file lean or send a link instead. Open Executions in the Apps Script editor to see how long your runs really take.
The whole file is exported
getAs on a spreadsheet converts the entire file, every tab, to a PDF. How tabs and page breaks come out follows Google's PDF export, so look at a test PDF before you rely on it. If you need a single tab, give that tab its own file.
Is the data fresh?
The script sends what Google computes when the export runs. Figures that depend on other files or on external imports are only as current as those links. If a number matters, open the PDF from a test run and check it against the source.
Sent once, retried the same day
The day is saved in a script property only after the email has been sent. If the send fails, the next hourly run tries again, up to MAX_ATTEMPTS_PER_DAY (6) times, so a short Google outage does not cost you a week. A crash after the send but before the save could send it twice, which is the safer way round for a weekly report. If all six attempts fail, nothing more is sent that day and the last alert says so.
Escaping
Every cell is escaped before it is placed in the HTML email, so a cell containing angle brackets or an ampersand shows as text and cannot add markup to the email. Nothing is written to the sheet, so there is no formula-injection path.
No time-budget loop
One run does one send, so there is no loop to stop early. The six minute limit would matter only for a very large file, and the failure alert covers that case.
Who gets alerted
The first failure emails ALERT_TO at once. Later failures within 6 hours are held back and sent together, the sixth failed attempt is always sent straight away, and when a retry finally succeeds any held-back failures are sent at once, so nothing waits a week. Problems found while an alert is being held back are kept and sent with the next one, so each is reported once. Use a shared mailbox so the alert is not tied to one person.
Ownership
The script runs as the account that installed it, and the email comes from that account. If that person leaves and their account is suspended, the report stops. Install it from a dedicated shared Workspace user that the business owns and keeps active.

When should you call us?

A weekly email from one sheet is a good size for a script. Call us when the report has to pull figures together from several systems first (accounting, a CRM, a shop), when different recipients need different cuts, when it needs written commentary, or when someone has to be accountable for it arriving correctly every week. We build and run that as production code with checks on the numbers before they go out.

Tell us what you need

Continues · Want this wired into your stack?

Want this wired into your stack?

Tell us your tools and the task. We'll scope it and build it as production code you own.

Book a Consultation

We typically respond within 24 hours