Zero
Zero
Back

Build an Email Summary Bot using Claude AI, Gmail, and Slack (Part 1)

Integrate with the Gmail Push Notifications API to enable your app to intelligently respond to new emails.

Sam Magura

Sam Magura

A tall office building

Ever have trouble staying on top of your overflowing email inbox? I know I do. Wouldn't it be nice if an AI could read our emails for us and send us a short summary of each message?

Just a few years ago, this idea would have sounded like a task for an elite research team at Google or Microsoft. But with powerful generative AI becoming widely available, anyone who can use an API can implement this idea.

In this two-part series, we'll create a "bot" that listens for new emails and then DMs you a summary of each email on Slack. We'll be using Claude , an AI assistant created by Anthropic, to do the summarizing. Calling the Claude API is extremely easy, so the more difficult parts of this project are actually integrating with Gmail and Slack.

Technical Overview

This post will cover the integration with Gmail, while Part 2 will cover the integration with Claude and Slack.

To receive notifications from the Gmail API, our application needs to expose a webhook URL, so we'll build a Node.js API using the Express  web framework.

We're dealing with three third-party APIs in this post (Gmail, Claude, and Slack), so the Zero secrets manager really shines here by allowing us to store all the API keys in one secure location. Then, we can use the Zero TypeScript SDK  to exchange a single Zero token for all the necessary API keys.

🔗 The full code for this example is available in the zerosecrets/examples  GitHub repository.

Secure your secrets conveniently

Zero is a modern secrets manager built with usability at its core. Reliable and secure, it saves time and effort.

Zero dashboard

Understading Gmail Push Notifications

Gmail can notify your server when an inbox receives a new message, as documented in the Gmail Push Notifications guide . I highly recommend reading the guide, since it is the basis for this walkthrough.

To get started with Gmail Push Notifications, you'll need a Pub/Sub resource in Google Cloud Platform. Log in to the Google Cloud Platform console and search "Pub/Sub" in the main search bar. Now, add a topic with the ID gmail. When Gmail receives a message, it will publish a message to this topic.

For our application to receive messages from the topic, we also need to create a subscription. While viewing the topic, click the "Create subscription" button. Name the subscription gmail-sub and set the delivery type to "Push". For now, enter an arbitrary endpoint URL like https://wikipedia.org/ ― we'll fix this later. We won't enable authentication for on our webhook URL, but you should do this for production applications.

Editing a subscription in Google Pub/Sub
Editing a subscription in Google Pub/Sub

Now, return to the topic in the Pub/Sub UI. Open the info panel on the right side of screen, where you can manage the permissions for the topic. Click "Add principal" and add gmail-api-push@system.gserviceaccount.com with the publish permission.

Bootstrapping an Express Application

To go further, we'll need an empty Node.js application that's set up with Express and TypeScript. Please check out my OpenAI blog post  for a step-by-step guide to setting up this type of application.

Watching the Inbox

Our Pub/Sub topic is now set up properly, but we haven't yet told Gmail it needs to publish to the topic upon receiving a new email. To do this, we'll need to call the watch endpoint of the Gmail API.

We can start out by following the Gmail API's Node.js Quickstart . The quickstart contains several buttons that you'll need to click prior to writing any code:

  1. Click the "Enable the API" button.
  2. Click "Go to OAuth consent screen" and complete the wizard.
  3. Click "Go to credentials" and save credentials.json into your app's root directory.

Now, let's get the quickstart code working. First, install the necessary libraries:

1
shell
npm install googleapis @google-cloud/local-auth

Now, paste the JavaScript code from the quickstart into src/gmail.ts. You should export the authorize and listLabels functions, and then call them from index.ts. I renamed authorize to authorizeGmail, so my index.ts looks like this:

1
2
3
4
5
6
typescript
const googleClient = await authorizeGmail()
console.log('Authorized with Google.')

await listLabels(googleClient)

/* ... Initialize the Express app ... */

Now, compile and run the application by typing npm start. A Google login prompt will open in your web browser. After giving the app access to your Gmail account, you should see a list of email labels in your terminal.

💡 credentials.json and token.json contain secrets, so make sure to add them to your .gitignore.

We actually need to watch the inbox, not list the inbox's labels, so let's swap out the listLabels function for the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
typescript
export async function watchInbox(client: OAuth2Client): Promise<string> {
  const gmail = google.gmail({version: 'v1', auth: client})
  const res = await gmail.users.watch({
    userId: 'me',
    requestBody: {
      topicName: 'projects/YOUR_PROJECT_NAME/topics/gmail',
      labelIds: ['INBOX'],
      labelFilterBehavior: 'INCLUDE',
    },
  })

  if (res.status >= 400) {
    console.log(res)
    throw new Error('Watching inbox failed.')
  }

  const historyId = res.data.historyId

  if (!historyId) {
    throw new Error('Got null historyId.')
  }

  return historyId
}

(Remember to replace YOUR_PROJECT_NAME with your actual project name.)

Running your application again will tell the Gmail API to send Pub/Sub messages to the gmail topic. Note that in a production app, you'll need to "renew" the watch at least every 7 days. See here  for details.

Storing the Credentials in Zero

Your application's ID, client secret, and other auth info are stored in the credentials.json file, which is read by the @google-cloud/local-auth package in the lines

1
2
3
4
typescript
client = await authenticate({
  scopes: SCOPES,
  keyfilePath: CREDENTIALS_PATH,
})

The fact that the secret credentials have to be loaded from the filesystem makes using Zero less intuitive than usual, but we can still do it.

To proceed, create a new project in Zero and add a Google Cloud Platform secret to it. Within the secret, set the name to CREDENTIALS and paste in the entire contents of credentials.json for the value. At this time, you should also add a token to your Zero project and save it somewhere on your computer.

Now we can use the Zero TypeScript SDK  ( npm install @zerosecrets/zero) to retrieve the credentials from Zero. In a new fetchSecrets.ts file, write:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typescript
import {zero} from '@zerosecrets/zero'

interface Secrets {
  'google-cloud-platform': {
    credentials: string
  }
}

export async function fetchSecrets(): Promise<Secrets> {
  if (!process.env.ZERO_TOKEN) {
    throw new Error('Did you forget to set the ZERO_TOKEN environment variable?')
  }

  const secrets = await zero({
    token: process.env.ZERO_TOKEN,
    pick: ['google-cloud-platform'],
  }).fetch()

  return secrets as unknown as Secrets
}

Then in index.ts, pass the Google secret as a new argument to authorizeGmail:

1
2
3
4
typescript
const secrets = await fetchSecrets()
console.log('Fetched secrets.')

const googleClient = await authorizeGmail(secrets.google.credentials)

Finally, modify the authorizeGmail function to write the credentials to the filesystem so that the file is there when @google-cloud/local-auth tries to read it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typescript
export async function authorizeGmail(credentialsSecret: string) {
  let client = await loadSavedCredentialsIfExist()
  if (client) {
    return client
  }

  await fs.writeFile(CREDENTIALS_PATH, credentialsSecret, {
    encoding: 'utf-8',
  })

  client = await authenticate({
    scopes: SCOPES,
    keyfilePath: CREDENTIALS_PATH,
  })
  if (client.credentials) {
    await saveCredentials(client)
  }
  return client
}

Writing the secret to the filesystem just so it can be read back into memory is not ideal, but it does work.

Writing the Gmail Webhook

Now that the Google API client is authorized and we've watched the inbox, we need to create an API method for Google Pub/Sub to call when a message is received. The code for this is simple thanks to Express:

1
2
3
4
5
6
7
8
9
10
typescript
let historyId = await watchInbox(googleClient)
console.log('Watched inbox.')

// ...

app.post('/api/summarize', async (req, res) => {
  const result = await queryNewMessages(googleClient, historyId)

  res.send(undefined)
})

Here, we're calling a not-yet-implemented queryNewMessages function which will retrieve the email contents from the Gmail API, based on the starting historyId that was returned by watchInbox.

In our API method, the req object will contain the message payload from Google Pub/Sub. That payload simply contains an updated history ID for the inbox. Since we'll be querying the inbox anyway, we don't actually need this history ID and can therefore ignore the req argument.

As is typical when developing a webhook, there's a problem because the webhook API method is exposed on localhost, while Google Pub/Sub can only send messages to a publicly-accessible URL. Thankfully, we can use a tool called ngrok  to bridge the gap. After installing ngrok, run ngrok http 3000 in a new terminal window to expose localhost:3000 publicly on the web. The ngrok output will display a long URL like https://b704-2605-a601-a68d-c700-dfca-e69c-cdcf-5edc.ngrok-free.app that routes to your app.

Now, return to the Google Cloud Platform console, and edit the Pub/Sub subscription we created earlier. Now you can replace the bogus endpoint URL we entered with your ngrok URL followed by /api/summarize.

To test that everything is working, try sending yourself an email. This should result in your app's API method being executed.

Querying the Inbox

Let's implement the queryNewMessages function that we referenced in the webhook API handler. In gmail.ts, we'll add the following call to the history.list  API endpoint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
typescript
interface QueryNewMessagesReturn {
  historyId: string
  messages: GmailMessage[]
}

export async function queryNewMessages(client: OAuth2Client, lastHistoryId: string): Promise<QueryNewMessagesReturn> {
  const gmail = google.gmail({version: 'v1', auth: client})

  // This will return only the first 100 emails. Iterating over multiple pages
  // is required if you need to retrieve more than that many emails.
  const res = await gmail.users.history.list(
    {
      userId: 'me',
      startHistoryId: lastHistoryId,
      labelId: 'INBOX',
    },
    {},
  )

  if (res.status >= 400) {
    console.log(res)
    throw new Error('Querying history failed.')
  }

  const historyId = res.data.historyId

  if (!historyId) {
    throw new Error('Got null historyId.')
  }

  // TODO
}

The result of history.list contains a new history ID, which should be used in subsequent calls to queryNewMessages, and an array of messages that were received since lastHistoryId. We're not done yet though, because res.data.history only contains metadata about the new messages, not the message content itself.

To get the message content, we can loop over res.data.history:

1
2
3
4
5
6
7
8
9
10
11
typescript
const messagePromises: Promise<GmailMessage>[] = []

for (const h of res.data.history ?? []) {
  for (const message of h.messages ?? []) {
    if (message.id) {
      messagePromises.push(getMessage(gmail, message.id))
    }
  }
}

return {historyId, messages: await Promise.all(messagePromises)}

Here, getMessage is a new function that queries the Gmail API for the contents of a message, using its ID. The getMessage function is a bit complex, because Gmail messages don't simply have a body string — they have a payload which can contain multiple parts. The payload parts are base64-encoded, so we use the Node.js Buffer object to decode them. (The built-in atob function won't work consistently here because it does not handle Unicode characters.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
typescript
export interface GmailMessage {
  from: string
  subject: string
  body: string
}

async function getMessage(gmail: gmail_v1.Gmail, messageId: string): Promise<GmailMessage> {
  const res = await gmail.users.messages.get({
    userId: 'me',
    id: messageId,
    format: 'full',
  })

  if (res.status >= 400) {
    console.log(res)
    throw new Error('Getting message failed.')
  }

  const from = res.data.payload?.headers?.find((h) => h.name === 'From')?.value ?? ''

  const subject = res.data.payload?.headers?.find((h) => h.name === 'Subject')?.value ?? ''

  // We assume that the email has a plaintext part. This is probably not a safe
  // assumption. Production applications should also handle HTML parts.
  const plainTextPart = res.data.payload?.parts?.find((p) => p.mimeType === 'text/plain')

  const body = Buffer.from(plainTextPart?.body?.data ?? '', 'base64').toString('utf-8')

  return {from, subject, body}
}

Back in index.ts, we can simply log the messages that were returned from Gmail and update the history ID.

1
2
3
4
5
6
7
8
typescript
app.post('/api/summarize', async (req, res) => {
  const result = await queryNewMessages(googleClient, historyId)

  historyId = result.historyId
  console.log(result.messages)

  res.send(undefined)
})

In Part 2, we'll send the message content to the Claude AI assistant to get a summary.

Next Steps

There you have it, a complete guide to getting started with Gmail Push Notifications!

All of the code shown here could be adapted to work in a production web application, with the exception of the code that interfaces with @google-cloud/local-auth. If you're building this functionality into a production app, you'll need to become well-versed with how Google handles authorization with 3rd party apps. This Google Workspace authentication and authorization guide  is a good starting point. You should also read the Gmail API scopes  reference page to figure out which scopes to request.


Other articles

A cube made of smaller cubes

Build an Email Summary Bot using Claude AI, Gmail, and Slack (Part 2)

Use the Claude AI assistant to summarize emails in just a few lines of code.

A document being signed

Using DocuSign to Request Signatures within your App

Kickstart your integration with the DocuSign eSignature API with this guide.

Secure your secrets

Zero is a modern secrets manager built with usability at its core. Reliable and secure, it saves time and effort.