Skip to content

Gmail invoices on autopilot with Bun

automation
gmail
bun
cli

Beginning of each month I collect last months invoices to send to my accountant for a monthly advance tax payment calculation.

I have to do it every month, by the 5th of each month. It’s boring enough for me to always be late. Only takes 30mins, sure, but the dread of actually doing it is more of an issue.

All the invoices I’m getting are from subscriptions. They land in my mailbox and get buried under spam, newsletters and actually useful emails.

A while ago I made myself a checklist of said subscriptions and used to go to every admin panel (bookmarked) of the tools I subscribe to and look for invoices. Less painful than searching manually in the emails.

Recently though I came across gog CLI. I authorized it and tried searching for the documents (I have the list of possible senders after all), it worked. Then I tried downloading PDF attachments from the matching threads, this worked as well. So, I decided to write a little Typescript utility to call gog with my predefined set of filters for me every month.

I used bun but node should be just fine as well. I decided to use gog instead of connecting directly to the Gmail API because I already had it authenticated and I liked the --json output option in the CLI - very script friendly. We’ll get to that.

If you’d like to follow along, start with setting up your gog CLI for Gmail API. This was probably the most cumbersome step of creating the script. But you can use it later to access your mail from CLI, how cool is that?

In my case, I had to authenticate 2 Gmail accounts - you’ll see me using gog’s --account argument for each command for that reason.

Anyways, once you’re done, let’s try searching for some emails: gog mail search "from:railway after:2026/02/01" --account testing123@gmail.com --max=5 --json

Adjust your filters and voila, you should see a list of threads enclosed in a JSON object:

{
  "nextPageToken": "",
  "threads": [
    {
      "id": "xxxxxxxxxxxxxxxx",
      "date": "2026-01-05 21:37",
      "from": "Railway Corporation <invoice+statements+acct_xxxxxxxxxxxxxxxx@stripe.com>",
      "subject": "Your receipt from Railway Corporation #xxxx-xxxx",
      "labels": [
        "IMPORTANT",
        "CATEGORY_UPDATES",
        "INBOX"
      ],
      "messageCount": 1
    },
    {
      "id": "xxxxxxxxxxxxxxxx",
      "date": "2026-02-05 13:37",
      "from": "Railway Corporation <invoice+statements+acct_xxxxxxxxxxxxxxxx@stripe.com>",
      "subject": "Your receipt from Railway Corporation #xxxx-xxxx",
      "labels": [
        "IMPORTANT",
        "CATEGORY_UPDATES",
        "INBOX"
      ],
      "messageCount": 1
    }
  ]
}

Perfect.

Small catch: All dates used in the search query are interpreted as midnight on that date in the PST timezone.

How to find attachments in these? Let’s take one id from the list and try gog mail get it: gog mail get xxxxxxxxxxxxxxxx --account testing123@gmail.com --json

This JSON is much longer, but we’re only really interested in the attachments key:

{
  "attachments": [
    {
      "filename": "Invoice-XXXXXXXX-XXXX.pdf",
      "size": 35051,
      "sizeHuman": "34.2 KB",
      "mimeType": "application/pdf",
      "attachmentId": "A..."
    },
    ...
  ],
  ...
}

To download it run: gog mail attachment <threadId> <attachmentId> --account testing123@gmail.com --json

It gives nice JSON output with path to the file:

{
  "bytes": 35051,
  "cached": false,
  "path": "/Users/testing/Library/Application Support/gogcli/gmail-attachments/XXXXXXXXXXXXXXXX_XXXXXXXX_attachment.bin"
}

Easy. Let’s glue it together.

import path from "node:path";

// Local directory for the PDFs
const outFilesDirectory = path.join(import.meta.dir, "files");
const senders = ["railway"];
const account = "testing123@gmail.com";
const after = "2026/02/01";

// Helper for running gog commands
const runGogAndParse = async (cmd: string[]): Promise<any> => {
  const proc = Bun.spawn(cmd);
  const out = await new Response(proc.stdout).text();

  return JSON.parse(out);
}

// `gog search` wrapper
const mailSearchCmd = ({ from, after, account }: { from: string, after: string, account: string }) => [
  "gog",
  "mail",
  "search",
  `from:${from} after:${after}`,
  "--account",
  account,
  "--max=5",
  "--json",
]

// `gog mail get` wrapper
const mailGetCmd = ({ threadId, account }: { threadId: string, account: string }) => [
  "gog",
  "mail",
  "get",
  threadId,
  "--account",
  account,
  "--json"
]

// `gog attachment` wrapper
const attachmentCmd = ({ attachmentId, threadId, account }: { attachmentId: string, threadId: string, account: string }) => [
  "gog",
  "mail",
  "attachment",
  threadId,
  attachmentId,
  "--account",
  account,
  "--json"
]

for(const from of senders) {
  // `gog mail search`
  const searchResp = await runGogAndParse(
    mailSearchCmd({ from, after, account }),
  );

  for(const thread of searchResp.threads) {
    // `gog mail get`
    const mail = await runGogAndParse(
      mailGetCmd({ threadId: thread.id, account }),
    );
    
    // Filter attachments by mime
    const pdfs = mail.attachments?.length ? mail.attachments.filter((a) =>
      a.mimeType.includes("pdf"),
    ) : [];
    
    for(const attachment of pdfs) {
      // Attachment download
      const download = await runGogAndParse(
        attachmentCmd({
          threadId: thread.id,
          attachmentId: attachment.attachmentId,
          account
        })
      );
      
      // Copy downloaded PDFs into local directory
      const inputFile = Bun.file(download.path);
      const outFile = Bun.file(
        path.join(outFilesDirectory, attachment.filename),
      );
      
      Bun.write(outFile, inputFile);
    }
  }
}

The full version I use adds logging, zod schemas for the JSON responses, and runs searches concurrently — you can find it here.


I run this on the 5th every month, eyeball the output folder and send to my accountant. Everything takes 30s instead of 30mins. Was it worth the time saving? Probably not. Am I not feeling the existential dread before the task anymore? Yup. Was it fun? Definitely. Goal accomplished.