@speles7172/message-client
v0.6.1
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.
The mail log: what was sent, and what became of it
createMailLog writes a row before every send, then marks it sent,
failed, simulated (a sender that deliberately sends nothing reported
{ simulated: true }) or suppressed. A text message goes through the same log
with channel: 'sms', so "what did we send this guest" has one answer.
const mail = createMailLog({ execute, tables, sender, from: 'Acme <[email protected]>' });
await mail.send({ to, cc: [], subject, text, html, category: 'guest.invite', entityType: 'Guest', entityId });
await mail.retry(id); // same bodies, same people, replies still go where the original's did
await mail.list({ entities: [{ entityType: 'Event', entityIds: [eventId] }, { entityType: 'Guest', entityIds: guestIds }] });Bounces and complaints. Point SES at an SNS topic, subscribe an HTTPS endpoint, and feed what arrives through two functions:
import { parseSesNotification, verifySnsMessage, isSnsUrl } from '@speles7172/message-client';
if (!(await verifySnsMessage(body, { topicArns: [TOPIC_ARN] }))) return res.status(403).end();
if (body.Type === 'SubscriptionConfirmation' && isSnsUrl(body.SubscribeURL)) await fetch(body.SubscribeURL);
const feedback = parseSesNotification(body);
if (feedback) await mail.recordFeedback(feedback);Verify first. The endpoint is public by nature, and what it does with a
bounce is stop mailing an address — unchecked, anybody could silence anybody.
A hard bounce or a complaint puts the address on the suppression list, and
every later email leaves it off (and says so on the row, in skipped); a message
with nobody left is logged suppressed rather than sent. Ask mailReached(record)
who a message actually went to — never its to. topicArns is required: a
signature proves only that some topic sent the message. And a report only
ever suppresses addresses the logged message was sent to. A soft bounce suppresses nothing,
and a later delivery report never overwrites a bounce or a complaint.
mail.suppressions lists, checks, adds and removes entries for an admin screen.
Upgrading from 0.5. messageTablesSql() now ends with mailLogUpgradeSql()
and the suppression table, all idempotent — run it again in a migration and a
0.5 log gains its new columns with its rows intact.
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
