Blog

Aggregate newsletter emails with a Google Apps Script webapp

today February 6, 2023

I don't know about you, but I have a love-hate relationship with newsletter emails. On one hand, newsletters have terrific content: I get new, in-depth information directly from the source. Newsletters keep me informed about the subjects I care about the most.

But I really, really dislike emails. For starters, I'm a zero-inbox kind of guy: I don't like emails sitting in my inbox. As soon as an email comes in, I'd like to process it and get rid of it right there and then. But I'm also very busy which means that I usually don't have the time to read newsletter emails when they decide to pop in.

I tried different remedies for this problem like snoozing the emails, but snoozing doesn't work for me, because I don't know in advance when I'll have time to read them, so I often snooze and re-snooze. I tried labeling emails, but that meant having to remember the label and to click through into each email.

Ultimately, I'm finding that email is great for receiving newsletters, not for maintaining them or reading them. I prefer to read newsletters like i'm reading Google News: I have a bookmarked web page that I access whenever I have free time. I want to have the same experience with newsletters: a single page that lists all the unread newsletter messages in my Gmail account. If I see something I like then I read it. And I want to be able to mark it as read, so that it doesn't show up again.

Luckily, this is something right up Google Apps Script's alley. Let's build a webapp!

Interested in customizing this script? Contact me

Let's list the tasks we need to accomplish:

  • Create an email alias
  • Create a Gmail rule for newsletter emails
  • Fetch threads
  • Fetch messages
  • Listen to GET requests
  • Create messages HTML
  • Label threads as read

The first two items are not part of the automation. This is something that I do in my Google Workspace account, and suggest that you do the same. I create an email alias that I use to register to newsletters. I then create a rule to move all emails sent to the alias to the archive and mark them read so that I don't have to see them in my inbox.

To get started, ensure that you have a few emails in your Gmail account that you can target with our automation. Then, create a new Google Apps Script file. Inside "Code.gs", we'll define a function to fetch all the unread newsletter threads:

function getThreads() {
  let threads = [];
  const queryMap = {
    to: 'newsletter@yourdomain.com',
    '-label': 'newsletter-read',
  };
  const query = Object.entries(queryMap)
    .map((e) => e.join(':'))
    .join(' ');
  let startingThread = 0;
  const maxThreads = 100;
  while (true) {
    const newThreads = GmailApp.search(query, startingThread, maxThreads);
    threads = threads.concat(newThreads);
    startingThread += maxThreads;
    if (newThreads.length < maxThreads) {
      break;
    }
  }
  return threads;
}

Above, we create an empty array that will contain our GmailThread objects. We create a map that lists our Gmail search criteria: Get all emails sent to the alias that are not labeled with "newsletter-read." We will apply the label whenever we click the "Mark Read" button in our webapp. We then iterate over the map and convert it to a simple string.

It's possible that we have more threads than we can bring back in one search. We therefore need to page through the thread requests, requesting a new bunch of threads until we exhaust them all. We break out of the loop when the number of returned number of threads is less than our specified maxThreads count.

We now need to pull the GmailThreads and conver them to an array of message objects that we will use to populate our web page with:

function getMessagesAsObjects() {
  const threads = getThreads();
  const messages = GmailApp.getMessagesForThreads(threads).flat();
  const messageObjects = messages.map((message) => ({
    threadId: message.getThread().getId(),
    subject: message.getSubject(),
    body: message.getBody(),
    received: message.getDate(),
  }));
  return messageObjects;
}

In the function above, we get the threads, and then get the messages for each array. We use "flat" to convert the nested array into a simple array. We then iterate through the GmailMessage array and from each one we pull the information that we want to display on the page. The threadId is needed for the "Mark Read" button. It will use the id to notify the server which thread to label as read or not-read (in case we change our minds and click the button a second time).

Ok, all that prefatory work was so that when receive a GET request to our webapp, we can send an HTML with a payload of the message objects that will be rendered dynamically on the page. Here's the function for that:

function doGet(e) {
  const htmlTemplate = HtmlService.createTemplateFromFile('Newsletters.html');
  htmlTemplate.messages = getMessagesAsObjects();
  const htmlOutput = htmlTemplate.evaluate();
  return htmlOutput;
}

Above we define the doGet function that Google Apps Script will run automatically whenever we visit the webapp URL. We create an HTML template using the file that we will build next. We get the message objects and attach them to the template, using a "messages" property. We evaluate the template and return it.

Add an HTML file to and name it Newsletters.html. The markup is pretty simple:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>Newsletters</h1>
    <div id="messages-container"></div>
    <template id="template-message">
      <div class="message-div">
        <div class="header">
          <button class="unread">Mark Read</button>
          <h5></h5>
          <div class="received"></div>
        </div>
        <div class="body"></div>
      </div>
    </template>
  </body>
</html>

The HTML markup includes an H1 header and a template element that we will use with client-side Javascript to render the messages. Include the following Javascript before the closing </body> tag:

<script>
  const messages = <?!= JSON.stringify(messages) ?>;
  const messagesContainer = document.querySelector('#messages-container')
  if (messages.length == 0) {
    messagesContainer.innerText = 'Nothing new to read'
  } else {
    populateHtml()
  }

  function populateHtml() {
    const template = document.querySelector('#template-message')
    messages.forEach(message => {
      const clone = template.content.cloneNode(true)
      clone.querySelector('button').dataset.id = message.threadId
      clone.querySelector('h5').innerText = message.subject
      clone.querySelector('.received').innerText = new Date(message.received).toLocaleDateString('en-US')
      clone.querySelector('.body').innerHTML = message.body
      messagesContainer.appendChild(clone)
    })
    document.querySelectorAll('button').forEach(button => {
      button.addEventListener('click', (e)=>{
        google.script.run.withSuccessHandler(onSuccessRead).toggleRead(e.target.dataset.id)
      })
    })
  }

  function onSuccessRead(id) {
    const el = document.querySelector(`[data-id="${id}"]`)
    if (el.innerText == 'Mark Read') {
      el.innerText = 'Mark Unread'
      el.classList.add('read')
    } else {
      el.innerText = 'Mark Read'
      el.classList.remove('read')
    }
  }
    </script>

Above, we use scriplets to force Google Apps Script to write the message object array directly into the page as if we typed it manually. We check the length of the array: it's possible that the server won't sent any message, in which case we display a "Nothing new to read" message.

We get the Template element and for each message object we clone it and populate the various element with the message data. We then append the clone to the message container.

Next, we define click event handlers for all the buttons to call the server's toggleRead function with the thread ID that we stored in the button's data-id attribute. We also define a success handler function which toggles the button.

The last bit is to write the toggleRead in our "Code.gs" file:

function toggleRead(id) {
  let labels = GmailApp.getUserLabels().map((l) => l.getName());
  const labelName = 'newsletter-read';
  if (!labels.includes(labelName)) {
    GmailApp.createLabel(labelName);
  }
  const label = GmailApp.getUserLabelByName(labelName);
  const thread = GmailApp.getThreadById(id);
  labels = thread.getLabels().map((l) => l.getName());
  if (labels.includes(labelName)) {
    label.removeFromThread(thread);
  } else {
    label.addToThread(thread);
  }
  return id;
}

Above, we get the names of all our user generated labels in Gmail. If our read label isn't included then we create it. We then get the thread by the ID we received and check if it already includes the label (ie, this is an even-numbered click on the button). In which case we remove the label. Otherwise we label the thread. Finally we return the ID to the client so that it can toggle the button.

Happy newsletter reading!

Interested in customizing this script? Contact me