@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-clientRequires 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-clientandconfig-clienttake: 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
onNotificationcallback 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-consoleimports 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
