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

@speles7172/message-client

v0.1.0

Published

Notifications, inquiries and email over Postgres — attached to any record, synced with a mailbox, through an executor and a mail sender you supply.

Readme

@speles7172/message-client

Notifications, inquiries and email — attached to any record, and kept in step with a mailbox.

One idea: a conversation about a record. An event raises a notification; a question opens a thread; a reply typed into a mail client lands in the same thread as one typed into the app. All three are addressed the same way — an entityType and an entityId this package never interprets — so the module works for invoices, job applications, shipments, and whatever the next application calls its records.

npm install @speles7172/message-client

Requires Node 22+ and Postgres. No dependencies at all — no pg, no AWS SDK, no mail library. You supply an executor and a mail sender.

The three seams

Everything application-specific crosses one of three interfaces, and that is what makes the module generic rather than a copy of one company's system.

import { createMessageService, defineTemplates } from '@speles7172/message-client';

const messages = createMessageService({
  // 1. How to run a query. A pg.Pool, @speles7172/sql-client, a Lambda bridge.
  execute: pool.query.bind(pool),

  // 2. How to turn a recipient id into a name and an address.
  directory: { lookup: (ids) => people.byIds(ids) },

  // 3. How to actually send mail. SES, Postmark, Resend, an SMTP relay.
  sender: { async send(mail) { /* twenty lines, next to your credentials */ } },

  registry: defineTemplates([...]),
  from: 'Acme <[email protected]>',
  linkFor: (type, id) => `https://app.acme.example/${type}s/${id}`,
});

None of the three is guessed at. This package has never heard of your users table, your URL scheme or your mail provider, and it does not want to.

Declaring what the application can say

The set of events is a code fact — something has to raise invoice.approved, and a row in a table cannot. The wording is not: it is a sentence somebody wants to change on a Tuesday without a deploy. So events are declared in code and the database holds only the edits.

const registry = defineTemplates([
  {
    key: 'invoice.approved',
    label: 'Invoice approved',
    category: 'invoices',
    channels: ['in_app', 'email'],
    variables: [
      { token: 'amount', label: 'Amount', format: 'currency', example: '1250' },
      { token: 'approver', label: 'Approver', example: 'Dina Katz' },
    ],
    defaults: {
      in_app: {
        subject: 'Invoice approved',
        body: '{{approver}} approved your {{amount|currency}} invoice.',
      },
      email: {
        subject: 'Invoice approved',
        body: '<p>{{approver}} approved your {{amount|currency}} invoice.</p><a href="{{link}}">View</a>',
      },
    },
  },
]);

defineTemplates refuses a declaration that can never work: a channel with no default content (it would silently never send), a merge tag the event does not supply (it renders empty in production and nowhere else), and a block token treated as a value (its markup would be escaped into the email as visible angle brackets). All three are invisible until an email goes out wrong, so they fail at startup where a test will see them.

Four events are built in — thread.message, thread.mention, thread.invited, thread.status — because this package raises them itself. Redeclare any of them to change the wording.

Raising an event

await messages.notify({
  event: 'invoice.approved',
  recipientIds: [invoice.ownerId],
  entityType: 'invoice',
  entityId: invoice.id,
  variables: { amount: invoice.total, approver: actor.name },
});

That writes one in-app row per recipient, sends one email to all of them, and records it in the mail log. Who hears about it can also be a configured rule rather than a list:

await messages.notifyEvent({
  event: 'invoice.approved',
  scope: invoice.departmentId,   // your scope, opaque here
  recipientIds: [invoice.ownerId], // used only when nothing is routed
});

Inquiries, and email that stays in step

const { thread } = await messages.startThread({
  entityType: 'invoice',
  entityId: invoice.id,
  subject: 'Late invoice',
  createdBy: actor.id,
  participantIds: [invoice.ownerId],
  body: 'Any update on this?',
});

await messages.postMessage({ threadId: thread.id, senderId: actor.id, body: 'Chasing.' });

Every participant gets the message in the app and by email. The outbound mail carries a signed reply address (reply+<threadId>.<signature>@your.domain) and threading headers, so a mail client groups the conversation and a reply comes straight back:

// In your inbound-mail webhook:
const outcome = await messages.receiveEmail({ to, from, text, html });
// { status: 'posted', threadId, message, reopened } | { status: 'ignored', reason }

The reply is stripped of its quoted history, posted with source: 'email', and fans out to everybody else exactly as an in-app reply does. Every refusal is a return value rather than an exception: an inbound mailbox receives whatever the internet sends it, and a webhook that throws on a bounce gets retried until the provider gives up.

The signature is what makes this safe. Without it the address is reply+<threadId>@domain, and thread ids are not secrets — they appear in URLs, in support tickets and in screenshots. The feature is inert until a domain and a secret are configured: no Reply-To is set and inbound mail is refused.

What it does not do

  • It provisions nothing. messageTablesSql() is yours to put in a migration. ensureMessageTables() exists for a project with no migration runner, and says so.
  • It applies no access control. Same stance audit-client, file-client and config-client take: a permissive default that looks like a permission system is worse than an obvious absence of one. The endpoint in front of it is the gate.
  • It does not push. Web push, websockets and Slack are one onNotification callback away, and none of them needs this package to own a subscription table.
  • It never invents a person. A recipient id it cannot resolve is delivered to under its own id rather than dropped.

The two entry points

  • @speles7172/message-client — Node. The stores, the SQL, the service, the HMAC for reply addresses.
  • @speles7172/message-client/core — dependency-free, browser-safe. The template engine, the mention grammar, the quoted-reply parser, the channel rules, the types. @speles7172/message-console imports this one, which is what lets the template preview on the settings page run the same renderer the Lambda runs.

Both are published as ESM and CommonJS, because Jest resolves a package through its require condition and an ESM-only package is unimportable from a ts-jest suite.

See docs/MESSAGES.md for the endpoints, the tables and the end-to-end wiring.

Licence

MIT