npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@msgly/gmail

v0.2.2

Published

Gmail adapter for Msgly — receive and reply to emails via Gmail API + Pub/Sub push

Readme

@msgly/gmail

Gmail adapter for Msgly. Receive new messages as hub.on('message') events via Google Cloud Pub/Sub push, send threaded replies via the Gmail REST API. Built for "agent on email channel" use cases — one bot mailbox, OAuth refresh token, pure WebCrypto.

Scope (v1)

This release ships text-only send + receive for a single mailbox per adapter (the bot's own inbox). It is the right shape for shared support inboxes, reply-bots, and agent automations on a dedicated mailbox.

Out of scope for v1 — these are planned, but absent today:

  • Sending attachments / inline images
  • Surfacing inbound attachments as media (the body text comes through; attachment bytes are not extracted)
  • Multi-user mailbox routing (one adapter = one mailbox)
  • Reading historical mail (only push-triggered fetch)

Install

npm install @msgly/core @msgly/gmail

How Gmail receive works

Gmail does not push email bodies to webhooks. The flow is:

  1. You call users.watch() once with a Pub/Sub topic name. Gmail starts publishing notifications to that topic whenever the inbox changes.
  2. Your Pub/Sub push subscription forwards each event to your webhook (<PUBLIC_URL>/webhook/gmail). The payload contains { emailAddress, historyId } — just the historyId, no message body.
  3. The adapter calls users.history.list from the previously-seen historyId, finds new message ids, fetches each via users.messages.get?format=full, and emits an inbound message per item.

The "last seen historyId" is held in adapter memory. On first notification after process boot, the adapter falls back to fetching recent unread INBOX messages so nothing is lost across deploys.

Quick start

import express from 'express';
import { createHub } from '@msgly/core';
import { createGmailAdapter } from '@msgly/gmail';

const hub = createHub();

hub.register(
  createGmailAdapter({
    clientId: process.env.GMAIL_CLIENT_ID!,
    clientSecret: process.env.GMAIL_CLIENT_SECRET!,
    refreshToken: process.env.GMAIL_REFRESH_TOKEN!,
    emailAddress: process.env.GMAIL_EMAIL!,
    pushAuth: {
      kind: 'jwt',
      expectedAudience: 'https://yourdomain.com/webhook/gmail',
    },
  }),
);

await hub.connect({ throwOnFailure: true });

hub.on('message', async (msg) => {
  if (msg.content.type === 'text') {
    await hub.send({
      channel: 'gmail',
      account: msg.account,
      contact: msg.contact,
      content: { type: 'text', text: `Auto-reply: I received "${msg.content.text}"` },
      // Thread the reply onto the original conversation.
      metadata: {
        threadId: msg.metadata?.threadId,
        messageId: msg.metadata?.messageId,
        subject: msg.metadata?.subject,
        references: msg.metadata?.references,
      },
    });
  }
});

const app = express();
app.use(express.json({ verify: (req, _r, buf) => ((req as any).rawBody = new Uint8Array(buf)) }));

const handlers = hub.createWebhookHandler();
app.post('/webhook/:channel', handlers.post);

app.listen(3000);

Config

interface GmailConfig {
  /** OAuth client (Google Cloud Console → Credentials → OAuth 2.0 Client ID). */
  clientId: string;
  clientSecret: string;
  /**
   * Long-lived refresh token for the agent mailbox. Run the OAuth consent
   * flow once with `prompt=consent&access_type=offline` to obtain.
   */
  refreshToken: string;
  /** The mailbox email (used as From: and account.channelAccountId). */
  emailAddress: string;

  /** How to verify inbound Pub/Sub webhooks. Pick one. */
  pushAuth:
    | { kind: 'jwt'; expectedAudience: string; expectedServiceAccountEmail?: string }
    | { kind: 'token'; token: string }
    | { kind: 'none' };  // dev only — DO NOT use in production

  maxMessagesPerNotification?: number;  // default 25
  // overrides for testing / private clouds:
  tokenUrl?: string;
  apiBase?: string;
  jwksUrl?: string;
  clockSkewSec?: number;  // default 300
}

Setup (one-time, ~30 minutes)

The setup is more involved than the chat channels because Pub/Sub needs to be wired up. Walk through it once and the runtime is just two env vars + a webhook URL.

1. Create an OAuth client

Google Cloud Console:

  • APIs & Services → Library → enable Gmail API
  • APIs & Services → OAuth consent screen → External or Internal — fill in basics, add scope https://www.googleapis.com/auth/gmail.modify. Add the bot's email as a test user.
  • Credentials → Create credentials → OAuth client ID → Web application → add http://localhost:8080/oauth-callback (or whatever you'll use) as a redirect URI
  • Copy Client IDGMAIL_CLIENT_ID
  • Copy Client secretGMAIL_CLIENT_SECRET

2. Get a refresh token for the agent mailbox

Run the consent flow once. Quickest path locally:

# Open in your browser, signed in as the agent mailbox:
https://accounts.google.com/o/oauth2/v2/auth\
?client_id=YOUR_CLIENT_ID\
&response_type=code\
&scope=https://www.googleapis.com/auth/gmail.modify\
&redirect_uri=http://localhost:8080/oauth-callback\
&access_type=offline\
&prompt=consent

After consenting, you'll be redirected to your localhost URL with ?code=.... Exchange that code for a refresh token:

curl https://oauth2.googleapis.com/token \
  -d code=THE_CODE \
  -d client_id=$GMAIL_CLIENT_ID \
  -d client_secret=$GMAIL_CLIENT_SECRET \
  -d redirect_uri=http://localhost:8080/oauth-callback \
  -d grant_type=authorization_code

Copy refresh_token from the JSON response → GMAIL_REFRESH_TOKEN.

Note: Google issues a refresh token only on the first consent. If you need to re-issue, revoke the app at myaccount.google.com/permissions and consent again.

3. Create the Pub/Sub topic and subscription

In the same Google Cloud project:

  • Pub/Sub → Topics → Create topic — name it gmail-inbox (or anything).
  • On the topic → PermissionsAdd principal[email protected] → role Pub/Sub Publisher. This is what lets Gmail publish into your topic.
  • Subscriptions → Create subscription on that topic:
    • Delivery type: Push
    • Endpoint: <PUBLIC_URL>/webhook/gmail
    • Authentication (recommended): tick "Enable authentication", create or pick a service account, and set audience to <PUBLIC_URL>/webhook/gmail (this matches pushAuth.expectedAudience in your config).
    • Or simpler-but-less-secure: append ?token=YOUR_RANDOM_SECRET to the endpoint URL and use pushAuth: { kind: 'token', token: '...' } instead.

4. Call watch() on the mailbox

Once at deploy time (and on a periodic schedule — watches expire after ~7 days):

const adapter = createGmailAdapter({ /* ... */ });
hub.register(adapter);

await adapter.watch('projects/your-project-id/topics/gmail-inbox');
// Returns { historyId } — the baseline.

Schedule a cron to call watch() daily so the subscription never expires.

5. Test

Send an email to the agent mailbox from another account. Your hub.on('message') handler should receive it.

Inbound shape

| Email field | msgly mapping | | --------------------- | ----------------------------------------------------- | | From <addr> | contact.channelUserId | | From "Name" | contact.displayName | | To (bot's address) | account.channelAccountId | | text/plain body | content.text | | Subject | metadata.subject | | Message-ID | metadata.messageId | | Gmail threadId | metadata.threadId | | References | metadata.references | | internalDate | timestamp |

When inbound has only HTML, the adapter strips tags into a best-effort plain-text body. Attachments are not yet surfaced as MediaContent (planned for v2).

Reply path

Pass any combination of these through metadata and the adapter does the right thing:

| metadata field | Effect on outbound | | ----------------- | ------------------------------------------------------------ | | threadId | Sent in the API call body — Gmail keeps the reply in-thread. | | messageId | Becomes the In-Reply-To header on the outgoing email. | | references | Becomes the References header (chain preservation). | | subject | Used (with auto Re: prefix) as the reply subject. |

Without any of these, the adapter still sends — just as a fresh email with subject (no subject) to the contact address.

Capabilities

| Feature | Supported | | ------------- | --------- | | text | ✓ | | image / video / audio / file | — (v2) | | location | — | | buttons | — | | reactions | — | | typing | — | | templates | — |

Production notes

Multi-instance deployments

The "last seen historyId" is held in adapter memory. If you horizontally scale (multiple Node processes behind a load balancer), each instance has its own historyId tracker, and notifications routed to a different instance than the previous one will see a stale baseline.

Mitigations:

  • Pin one process per inbox (sticky routing for the /webhook/gmail path), OR
  • Run a single worker for Gmail webhook handling (load balancer routes only one backend), OR
  • Wait for v2 which will accept a historyIdStore callback so you can persist to Redis/Postgres.

In all cases, msgly's externalId-based idempotency means duplicate fetches are deduplicated, so the worst-case observable behavior is briefly missed messages (not duplicate emits). For a v1 deploy on a single instance, this is not a concern.

Push subscription authentication

The three modes ranked by security:

  1. { kind: 'jwt' } (recommended for production) — Pub/Sub signs each push with an OIDC token, we verify against Google's JWKS. Strongest. Requires enabling authentication on the Pub/Sub subscription.
  2. { kind: 'token' } — A shared secret appended as ?token=... to the push endpoint URL. The token lives in your Cloud Pub/Sub configuration and your env vars; if either leaks, an attacker can forge inbound notifications. Comparison is constant-time. Acceptable for staging, not great for production.
  3. { kind: 'none' } — Skips verification entirely. Local development only. Anyone who knows your URL can forge inbound messages.

Header injection

The adapter strips CR/LF from every header value (From, To, Subject, In-Reply-To, References) before constructing the outgoing RFC 5322 email. Even if upstream metadata is adversarial (malicious sender, compromised user input), the outgoing message can't have additional injected headers like Bcc: or Reply-To:.

Common pitfalls

  • No notifications arriving: confirm the Pub/Sub topic grants [email protected] publish access. Confirm users.watch() returned a historyId (didn't error). Watches expire after ~7 days — schedule a daily re-call.
  • 401 unauthorized on the webhook: if you used pushAuth: { kind: 'jwt' }, the Pub/Sub subscription must have authentication enabled and the audience must match expectedAudience exactly (case-sensitive, no trailing slash mismatch).
  • invalid_grant from the token endpoint: refresh token revoked or never had access_type=offline. Re-run consent with prompt=consent.
  • Inbound shows wrong sender: this adapter parses From: as "Name" <addr> or bare address. Exotic header forms (group syntax, etc.) fall back to the raw header — file a bug if you hit one.
  • Reply doesn't thread correctly in some clients: pass through both metadata.threadId AND metadata.messageId. Gmail uses threadId; other clients honor In-Reply-To.

Documentation

Full multi-channel docs: https://github.com/AyushJain070401/msgly

License

MIT