@happyvertical/smrt-messages 0.60.10 → 0.60.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/README.md +34 -0
- package/agents/notification-outbox.md +48 -2
- package/dist/chunks/{AccountCollection-By48bdwt.js → AccountCollection-BGzfQG2I.js} +2 -2
- package/dist/chunks/{AccountCollection-By48bdwt.js.map → AccountCollection-BGzfQG2I.js.map} +1 -1
- package/dist/chunks/{Email-CMUK3aWU.js → Email-CBIrfFLm.js} +3 -3
- package/dist/chunks/{Email-CMUK3aWU.js.map → Email-CBIrfFLm.js.map} +1 -1
- package/dist/chunks/{EmailFolder-DLJvqNqI.js → EmailFolder-Cz2JU5Z1.js} +2 -2
- package/dist/chunks/{EmailFolder-DLJvqNqI.js.map → EmailFolder-Cz2JU5Z1.js.map} +1 -1
- package/dist/chunks/{Message-DXpqot5f.js → Message-DiO_FvAR.js} +4 -4
- package/dist/chunks/{Message-DXpqot5f.js.map → Message-DiO_FvAR.js.map} +1 -1
- package/dist/chunks/{MessageCollection-CMGaSGBc.js → MessageCollection-uui10uhg.js} +2 -2
- package/dist/chunks/{MessageCollection-CMGaSGBc.js.map → MessageCollection-uui10uhg.js.map} +1 -1
- package/dist/chunks/__smrt-register__-DyTpUDMS.js +6 -0
- package/dist/chunks/__smrt-register__-DyTpUDMS.js.map +1 -0
- package/dist/collections/NotificationOptOutCollection.d.ts +6 -0
- package/dist/collections/NotificationOptOutCollection.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +264 -55
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +330 -1
- package/dist/models/NotificationOptOut.d.ts +44 -0
- package/dist/models/NotificationOptOut.d.ts.map +1 -0
- package/dist/services/NotificationOptOutService.d.ts +80 -0
- package/dist/services/NotificationOptOutService.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +104 -10
- package/dist/smrt-knowledge.okf.md +4 -2
- package/package.json +9 -9
- package/dist/chunks/__smrt-register__-CaGLtiEU.js +0 -6
- package/dist/chunks/__smrt-register__-CaGLtiEU.js.map +0 -1
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-messages",
|
|
6
|
-
"packageVersion": "0.60.
|
|
6
|
+
"packageVersion": "0.60.11",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
12
|
-
"agents": "
|
|
13
|
-
"moduleDoc:agents/notification-outbox.md": "
|
|
10
|
+
"manifest": "fa496fbecf14dd88cff8c80445d4e21125abf2242b4bae0d1ee093bf2875216b",
|
|
11
|
+
"packageJson": "47ae1990a3090feb3d7373881e730c091a23273015fde2bea5b12f9c3e53cf4d",
|
|
12
|
+
"agents": "25944fbc57127569409696f81aa8bac48529378364e4a59f57586b16780281ae",
|
|
13
|
+
"moduleDoc:agents/notification-outbox.md": "ba0479c05ffafd22be62e20c0fb8c6eb6db9294e95fd6d988ac1e2f47510e98c"
|
|
14
14
|
},
|
|
15
15
|
"exports": [
|
|
16
16
|
".",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"typescript"
|
|
81
81
|
],
|
|
82
82
|
"risks": [
|
|
83
|
-
"cross-package-refs:
|
|
83
|
+
"cross-package-refs:9",
|
|
84
84
|
"sensitive-fields-excluded",
|
|
85
85
|
"sti-inheritance"
|
|
86
86
|
],
|
|
@@ -2823,6 +2823,23 @@
|
|
|
2823
2823
|
"tags": [],
|
|
2824
2824
|
"risks": []
|
|
2825
2825
|
},
|
|
2826
|
+
{
|
|
2827
|
+
"name": "NotificationOptOutCollection",
|
|
2828
|
+
"qualifiedName": "@happyvertical/smrt-messages:NotificationOptOutCollection",
|
|
2829
|
+
"collection": "notificationoptouts",
|
|
2830
|
+
"tableName": "notification_opt_outs",
|
|
2831
|
+
"packageName": "@happyvertical/smrt-messages",
|
|
2832
|
+
"extends": "SmrtCollection",
|
|
2833
|
+
"fields": [],
|
|
2834
|
+
"relationships": [],
|
|
2835
|
+
"methods": [],
|
|
2836
|
+
"surfaces": [],
|
|
2837
|
+
"relationshipFeatures": [
|
|
2838
|
+
"uuidColumns"
|
|
2839
|
+
],
|
|
2840
|
+
"tags": [],
|
|
2841
|
+
"risks": []
|
|
2842
|
+
},
|
|
2826
2843
|
{
|
|
2827
2844
|
"name": "PersonaMessageRouteCollection",
|
|
2828
2845
|
"qualifiedName": "@happyvertical/smrt-messages:PersonaMessageRouteCollection",
|
|
@@ -7008,6 +7025,83 @@
|
|
|
7008
7025
|
"tags": [],
|
|
7009
7026
|
"risks": []
|
|
7010
7027
|
},
|
|
7028
|
+
{
|
|
7029
|
+
"name": "NotificationOptOut",
|
|
7030
|
+
"qualifiedName": "@happyvertical/smrt-messages:NotificationOptOut",
|
|
7031
|
+
"collection": "notificationoptouts",
|
|
7032
|
+
"description": "One person's opt-out of one notification channel for one scope, in one tenant: the preference store behind the outbox's isSuppressed hook.",
|
|
7033
|
+
"tableName": "notification_opt_outs",
|
|
7034
|
+
"packageName": "@happyvertical/smrt-messages",
|
|
7035
|
+
"extends": "SmrtObject",
|
|
7036
|
+
"fields": [
|
|
7037
|
+
{
|
|
7038
|
+
"name": "tenantId",
|
|
7039
|
+
"type": "text",
|
|
7040
|
+
"required": true,
|
|
7041
|
+
"columnType": "UUID"
|
|
7042
|
+
},
|
|
7043
|
+
{
|
|
7044
|
+
"name": "recipientUserId",
|
|
7045
|
+
"type": "crossPackageRef",
|
|
7046
|
+
"required": true,
|
|
7047
|
+
"related": "@happyvertical/smrt-users:User",
|
|
7048
|
+
"columnType": "UUID"
|
|
7049
|
+
},
|
|
7050
|
+
{
|
|
7051
|
+
"name": "channel",
|
|
7052
|
+
"type": "text",
|
|
7053
|
+
"required": true,
|
|
7054
|
+
"columnType": "TEXT",
|
|
7055
|
+
"default": "email"
|
|
7056
|
+
},
|
|
7057
|
+
{
|
|
7058
|
+
"name": "scope",
|
|
7059
|
+
"type": "text",
|
|
7060
|
+
"required": true,
|
|
7061
|
+
"columnType": "TEXT"
|
|
7062
|
+
},
|
|
7063
|
+
{
|
|
7064
|
+
"name": "optedOutAt",
|
|
7065
|
+
"type": "datetime",
|
|
7066
|
+
"required": false,
|
|
7067
|
+
"columnType": "TIMESTAMP"
|
|
7068
|
+
},
|
|
7069
|
+
{
|
|
7070
|
+
"name": "updatedBy",
|
|
7071
|
+
"type": "text",
|
|
7072
|
+
"required": false,
|
|
7073
|
+
"columnType": "TEXT"
|
|
7074
|
+
}
|
|
7075
|
+
],
|
|
7076
|
+
"relationships": [
|
|
7077
|
+
{
|
|
7078
|
+
"name": "recipientUserId",
|
|
7079
|
+
"type": "crossPackageRef",
|
|
7080
|
+
"required": true,
|
|
7081
|
+
"related": "@happyvertical/smrt-users:User",
|
|
7082
|
+
"columnType": "UUID"
|
|
7083
|
+
}
|
|
7084
|
+
],
|
|
7085
|
+
"methods": [],
|
|
7086
|
+
"tenant": {
|
|
7087
|
+
"scoped": true,
|
|
7088
|
+
"mode": "required",
|
|
7089
|
+
"field": "tenantId"
|
|
7090
|
+
},
|
|
7091
|
+
"conflictColumns": [
|
|
7092
|
+
"tenant_id",
|
|
7093
|
+
"recipient_user_id",
|
|
7094
|
+
"channel",
|
|
7095
|
+
"scope"
|
|
7096
|
+
],
|
|
7097
|
+
"surfaces": [],
|
|
7098
|
+
"relationshipFeatures": [
|
|
7099
|
+
"crossPackageRef",
|
|
7100
|
+
"uuidColumns"
|
|
7101
|
+
],
|
|
7102
|
+
"tags": [],
|
|
7103
|
+
"risks": []
|
|
7104
|
+
},
|
|
7011
7105
|
{
|
|
7012
7106
|
"name": "PersonaMessageRoute",
|
|
7013
7107
|
"qualifiedName": "@happyvertical/smrt-messages:PersonaMessageRoute",
|
|
@@ -12643,18 +12737,18 @@
|
|
|
12643
12737
|
"prompts": [],
|
|
12644
12738
|
"relationshipsV2": {
|
|
12645
12739
|
"foreignKeyFields": 18,
|
|
12646
|
-
"crossPackageRefFields":
|
|
12740
|
+
"crossPackageRefFields": 9,
|
|
12647
12741
|
"junctionCollections": 0,
|
|
12648
12742
|
"hierarchicalObjects": 0,
|
|
12649
12743
|
"polymorphicAssociations": 0,
|
|
12650
|
-
"uuidColumns":
|
|
12744
|
+
"uuidColumns": 112
|
|
12651
12745
|
},
|
|
12652
|
-
"agentDoc": "# @happyvertical/smrt-messages\n\nMulti-channel messaging with STI hierarchies for both messages and accounts. Credential encryption via smrt-secrets.\n\n## STI Hierarchies\n\n**Messages** (share `messages` table):\n- `Message` (base): accountId, personaId, endpointId, correlationId, threadId, subject, body, fromAddress, toAddresses (JSON), sendStatus (draft/sending/sent/failed), retryCount\n- `Email`: messageId (RFC 822), inReplyTo, ccAddresses, bccAddresses, htmlBody, textBody, folderId, labels, headers\n- `Tweet`: tweetId, retweetCount, likeCount, mediaUrls, hashtags, mentions\n- `SlackMessage`: channelId, slackTs, slackThreadTs, reactions, blocks\n\n**Accounts** (share `accounts` table):\n- `Account` (base): channelType, providerType, public configuration,\n credentialSecretId, isActive, lastSyncAt, deprecated sensitive settings\n- `EmailAccount`, `TwitterAccount`, `SlackAccount`, `ZulipAccount`,\n `TelegramAccount`: provider-specific identity/sender behavior\n\n## Persona messaging\n\n- `MessagingEndpoint`: tenant-owned, write-only destination JSON with masked\n display helper.\n- `PersonaMessageRoute`: persona → account + endpoint, selected by purpose and\n descending priority.\n- `PersonaMessagingService`: persists and sends through an enabled route.\n- Provider registry: public setup descriptors + optional sender factory. Keep\n channel (`sms`) separate from provider (`twilio`).\n- Permissions: `messages.send`, `messages.manage-routes`,\n `messages.manage-credentials`.\n- `MessagingSettingsService` is the permissioned server boundary;\n its host-supplied `resolvePersonaTenantId` proves cross-package ownership.\n `MessagingSettingsPanel` is the callback-driven Svelte surface.\n- Account, endpoint, and route generated surfaces are read-only. Do not add\n generated mutations around the settings service or make secret fields writable.\n- **Recipe `messages.slack`** (`src/recipes.ts`, help in `src/help/slack.md`): lists Slack messages and workspaces beside the Mailbox and Notifications recipes; workspaces stay read-only and sending goes through the senders, not generated routes. Fields it shows carry `@field({ description })` in plain words; keep them when editing the models.\n\n## In-app notifications (`UserNotification`)\n\nThe in-app channel: one stored row per person, with read state.\n`user_notifications`: `tenantId` (required), `recipientUserId`\n(`@crossPackageRef` smrt-users:User, uuid), `kind`, `title`, `body`, `href`,\n`severity` (info/success/warning/error), `sourceRef`, `occurredAt`, `readAt`,\n`dismissedAt`. Conflict identity `(tenant_id, recipient_user_id, source_ref)`.\n\n- `UserNotificationService` is the only access path: `notify()` /\n `notifyMany()` (dedupe on `sourceRef`, existing row returned untouched;\n a unique ref is generated when omitted), `listForUser()` / `countUnread()`\n across explicit `tenantIds` (each read under `withTenant`), `markRead()`,\n `dismiss()` (also marks read), `markAllRead()`. Every write filters on the\n recipient, so another user's id matches nothing.\n- Generated REST/MCP/CLI are off (`api`/`mcp`/`cli: false`): a tenant-scoped\n list would expose every member's rows. Hosts expose their own routes that\n pass the signed-in user's id, never a request-chosen one.\n- Hosts decide who is told (e.g. `SocialPost.createdByUserId` for a failed\n post). New table: consumers run `db:migrate`.\n\n## Notification email outbox (`NotificationDelivery`)\n\n`NotificationOutboxService` is the external-channel counterpart of\n`UserNotificationService`: `enqueueEmail()` stores one delivery per\n`(tenant, recipient, channel, sourceRef)` after the host resolves the\nrecipient address and same-tenant sender account (callers never choose an\naddress); `dispatchDue()` leases due rows by compare-and-set\n(`NotificationDelivery.transition()`), creates the single outbound `Email`\nunder the lease, and settles sent/retry/failed/uncertain. Never resend an\n`uncertain` row automatically. Contract, retry, and reconciliation policy:\n[agents/notification-outbox.md](agents/notification-outbox.md). New table:\nconsumers run `db:migrate`.\n\n## Credential Security\n\nAccount credentials stored via tenant-bound `credentialSecretId` →\nsmrt-secrets. Use `setCredentials()`/`getCredentials()` — never store passwords\ndirectly. Credential descriptor schemas contain field definitions, never values.\n\n## Send Lifecycle\n\n`message.send()`: resolves account → creates provider sender → updates sendStatus. Retry support with `maxRetries` budget.\nA failure the provider may still have accepted carries\n`MessageSendResult.outcomeUnknown` (set by `EmailSender` unless\n`isDefiniteSendRejection()` proves a refusal, and when a sender throws) and is\npersisted as `metadata.sendOutcomeUnknown`; never auto-resend it.\n`EmailSender` attaches the message's stored `Attachment` rows (read through\n`Attachment.readContent()`, under the message's tenant context) plus\n`SendEmailOptions.attachments` bytes; an unreadable or file-less row fails the\nsend with the error on `sendError`, never a silent drop (#3808).\n`PersonaMessagingService.send({ attachments })` accepts them on email routes only.\n\n## Provider entry points (bundle boundary, #1979)\n\nThe package root is provider-neutral: models, collections, services,\npermissions, and the provider registry carry **zero provider-SDK weight**.\nProvider SDK wrappers (`@happyvertical/email`, `@happyvertical/messages`) are\nreachable only through explicit entry points, imported once at server startup:\n\n```ts\nimport '@happyvertical/smrt-messages/providers/email'; // smtp/imap/pop3/gmail\nimport '@happyvertical/smrt-messages/providers/slack';\nimport '@happyvertical/smrt-messages/providers/twitter';\nimport '@happyvertical/smrt-messages/providers/all'; // all of the above\n```\n\n- Without the matching entry, `EmailAccount.createClient()` (send + mailbox\n sync) throws an actionable error and `SlackSender`/`TweetSender` return a\n failure result naming the entry to import. Telegram and Zulip are\n fetch-based and always work from the root.\n- Builtin registry definitions stay metadata-only (setup fields for UIs); the\n entries upgrade them with `createEmailClient`/`createMessageClient`/\n `createSender` hooks. Never import a provider SDK from any module reachable\n from `src/index.ts` — the consumer bundle gate (`packages/bundle-gate`)\n fails CI when one becomes reachable.\n- `Attachment.readContent()` loads `@happyvertical/files` through core's\n `importOptionalDependency` boundary and keeps its null-on-failure contract\n in builds where the SDK is absent.\n\n## Gotchas\n\n- **Manifest before decorators**: every decorated model imports\n `../__smrt-register__.js`, and `package.json#sideEffects` lists\n `./src/__smrt-register__.ts`. Without both, the library build dropped or\n reordered the self-registration, so dist classes registered with only their\n decorated fields and could adopt smrt-ledgers' same-named `Account` (#3098).\n- **`Account` is not unique**: smrt-ledgers also declares `Account` (on\n `ledger_accounts`). Resolve by constructor or qualified name; the\n coexistence tests (`ledger-account-identity-*`, and the PostgreSQL lane\n `pnpm test:postgres`) load both packages in both orders.\n- **STI `_meta_type`**: qualified format `@happyvertical/smrt-messages:Email`\n- **JSON fields**: all address/metadata fields are JSON strings with `getX()`/`setX()` helpers\n- **RFC 822 threading**: Email uses `inReplyTo`/`messageId`/`references` — manual management required\n- **Attachment.messageId**: use this field (not `emailId`); old getter/setter mapped via deprecation wrapper\n- **Optional tenancy**: `@TenantScoped({ mode: 'optional' })` on Message, Account, Attachment\n- **Required tenancy**: endpoints and persona routes are always tenant scoped\n- **Secrets are write-only**: never serialize credentials or endpoint.address to an app\n\n## Intake byte snapshots\n\n`EmailAccount.readIntakeMessage(providerLookup, maxBytes, imapIdentity?)` is the authorized host seam for\nopt-in ingestion. It copies full-message attachment bytes from the registered\nemail provider and fails when only attachment metadata is available; it never\nreads provider-supplied paths or advances sync checkpoints. Keep this path\nprovider-neutral and exclude arbitrary headers/credentials from its snapshot.\nIMAP lookup must include folder/UIDVALIDITY/UID verification; sender Message-ID\nis a lookup hint, never the durable delivery identity.\n",
|
|
12746
|
+
"agentDoc": "# @happyvertical/smrt-messages\n\nMulti-channel messaging with STI hierarchies for both messages and accounts. Credential encryption via smrt-secrets.\n\n## STI Hierarchies\n\n**Messages** (share `messages` table):\n- `Message` (base): accountId, personaId, endpointId, correlationId, threadId, subject, body, fromAddress, toAddresses (JSON), sendStatus (draft/sending/sent/failed), retryCount\n- `Email`: messageId (RFC 822), inReplyTo, ccAddresses, bccAddresses, htmlBody, textBody, folderId, labels, headers\n- `Tweet`: tweetId, retweetCount, likeCount, mediaUrls, hashtags, mentions\n- `SlackMessage`: channelId, slackTs, slackThreadTs, reactions, blocks\n\n**Accounts** (share `accounts` table):\n- `Account` (base): channelType, providerType, public configuration,\n credentialSecretId, isActive, lastSyncAt, deprecated sensitive settings\n- `EmailAccount`, `TwitterAccount`, `SlackAccount`, `ZulipAccount`,\n `TelegramAccount`: provider-specific identity/sender behavior\n\n## Persona messaging\n\n- `MessagingEndpoint`: tenant-owned, write-only destination JSON with masked\n display helper.\n- `PersonaMessageRoute`: persona → account + endpoint, selected by purpose and\n descending priority.\n- `PersonaMessagingService`: persists and sends through an enabled route.\n- Provider registry: public setup descriptors + optional sender factory. Keep\n channel (`sms`) separate from provider (`twilio`).\n- Permissions: `messages.send`, `messages.manage-routes`,\n `messages.manage-credentials`.\n- `MessagingSettingsService` is the permissioned server boundary;\n its host-supplied `resolvePersonaTenantId` proves cross-package ownership.\n `MessagingSettingsPanel` is the callback-driven Svelte surface.\n- Account, endpoint, and route generated surfaces are read-only. Do not add\n generated mutations around the settings service or make secret fields writable.\n- **Recipe `messages.slack`** (`src/recipes.ts`, help in `src/help/slack.md`): lists Slack messages and workspaces beside the Mailbox and Notifications recipes; workspaces stay read-only and sending goes through the senders, not generated routes. Fields it shows carry `@field({ description })` in plain words; keep them when editing the models.\n\n## In-app notifications (`UserNotification`)\n\nThe in-app channel: one stored row per person, with read state.\n`user_notifications`: `tenantId` (required), `recipientUserId`\n(`@crossPackageRef` smrt-users:User, uuid), `kind`, `title`, `body`, `href`,\n`severity` (info/success/warning/error), `sourceRef`, `occurredAt`, `readAt`,\n`dismissedAt`. Conflict identity `(tenant_id, recipient_user_id, source_ref)`.\n\n- `UserNotificationService` is the only access path: `notify()` /\n `notifyMany()` (dedupe on `sourceRef`, existing row returned untouched;\n a unique ref is generated when omitted), `listForUser()` / `countUnread()`\n across explicit `tenantIds` (each read under `withTenant`), `markRead()`,\n `dismiss()` (also marks read), `markAllRead()`. Every write filters on the\n recipient, so another user's id matches nothing.\n- Generated REST/MCP/CLI are off (`api`/`mcp`/`cli: false`): a tenant-scoped\n list would expose every member's rows. Hosts expose their own routes that\n pass the signed-in user's id, never a request-chosen one.\n- Hosts decide who is told (e.g. `SocialPost.createdByUserId` for a failed\n post). New table: consumers run `db:migrate`.\n\n## Notification email outbox (`NotificationDelivery`)\n\n`NotificationOutboxService` is the external-channel counterpart of\n`UserNotificationService`: `enqueueEmail()` stores one delivery per\n`(tenant, recipient, channel, sourceRef)` after the host resolves the\nrecipient address and same-tenant sender account (callers never choose an\naddress); `dispatchDue()` leases due rows by compare-and-set\n(`NotificationDelivery.transition()`), creates the single outbound `Email`\nunder the lease, and settles sent/retry/failed/uncertain. Never resend an\n`uncertain` row automatically. Contract, retry, and reconciliation policy:\n[agents/notification-outbox.md](agents/notification-outbox.md). New table:\nconsumers run `db:migrate`.\n\n`NotificationOptOut` (`notification_opt_outs`, tenant-required, generated\nsurfaces off) is the per-recipient opt-out ledger for the outbox's\n`isSuppressed` hook, keyed `(tenant, recipient, channel, scope)` with scope `*`\nor `project:<uuid>`-style keys. `NotificationOptOutService`: idempotent\n`optOut`/`optIn` (with a `principal` only `userId === recipientUserId` may\nchange a row, and only in the principal's own `tenantId`, else\n`NotificationOptOutDeniedError`; no principal = trusted host code), trusted `isOptedOut` (a `*` row matches every scope), `listForRecipient`\n(principal required). `optOutSuppression(service, scopeOf)` builds the\n`isSuppressed` function. Details in the outbox doc; consumers run `db:migrate`.\n\n## Credential Security\n\nAccount credentials stored via tenant-bound `credentialSecretId` →\nsmrt-secrets. Use `setCredentials()`/`getCredentials()` — never store passwords\ndirectly. Credential descriptor schemas contain field definitions, never values.\n\n## Send Lifecycle\n\n`message.send()`: resolves account → creates provider sender → updates sendStatus. Retry support with `maxRetries` budget.\nA failure the provider may still have accepted carries\n`MessageSendResult.outcomeUnknown` (set by `EmailSender` unless\n`isDefiniteSendRejection()` proves a refusal, and when a sender throws) and is\npersisted as `metadata.sendOutcomeUnknown`; never auto-resend it.\n`EmailSender` attaches the message's stored `Attachment` rows (read through\n`Attachment.readContent()`, under the message's tenant context) plus\n`SendEmailOptions.attachments` bytes; an unreadable or file-less row fails the\nsend with the error on `sendError`, never a silent drop (#3808).\n`PersonaMessagingService.send({ attachments })` accepts them on email routes only.\n\n## Provider entry points (bundle boundary, #1979)\n\nThe package root is provider-neutral: models, collections, services,\npermissions, and the provider registry carry **zero provider-SDK weight**.\nProvider SDK wrappers (`@happyvertical/email`, `@happyvertical/messages`) are\nreachable only through explicit entry points, imported once at server startup:\n\n```ts\nimport '@happyvertical/smrt-messages/providers/email'; // smtp/imap/pop3/gmail\nimport '@happyvertical/smrt-messages/providers/slack';\nimport '@happyvertical/smrt-messages/providers/twitter';\nimport '@happyvertical/smrt-messages/providers/all'; // all of the above\n```\n\n- Without the matching entry, `EmailAccount.createClient()` (send + mailbox\n sync) throws an actionable error and `SlackSender`/`TweetSender` return a\n failure result naming the entry to import. Telegram and Zulip are\n fetch-based and always work from the root.\n- Builtin registry definitions stay metadata-only (setup fields for UIs); the\n entries upgrade them with `createEmailClient`/`createMessageClient`/\n `createSender` hooks. Never import a provider SDK from any module reachable\n from `src/index.ts` — the consumer bundle gate (`packages/bundle-gate`)\n fails CI when one becomes reachable.\n- `Attachment.readContent()` loads `@happyvertical/files` through core's\n `importOptionalDependency` boundary and keeps its null-on-failure contract\n in builds where the SDK is absent.\n\n## Gotchas\n\n- **Manifest before decorators**: every decorated model imports\n `../__smrt-register__.js`, and `package.json#sideEffects` lists\n `./src/__smrt-register__.ts`. Without both, the library build dropped or\n reordered the self-registration, so dist classes registered with only their\n decorated fields and could adopt smrt-ledgers' same-named `Account` (#3098).\n- **`Account` is not unique**: smrt-ledgers also declares `Account` (on\n `ledger_accounts`). Resolve by constructor or qualified name; the\n coexistence tests (`ledger-account-identity-*`, and the PostgreSQL lane\n `pnpm test:postgres`) load both packages in both orders.\n- **STI `_meta_type`**: qualified format `@happyvertical/smrt-messages:Email`\n- **JSON fields**: all address/metadata fields are JSON strings with `getX()`/`setX()` helpers\n- **RFC 822 threading**: Email uses `inReplyTo`/`messageId`/`references` — manual management required\n- **Attachment.messageId**: use this field (not `emailId`); old getter/setter mapped via deprecation wrapper\n- **Optional tenancy**: `@TenantScoped({ mode: 'optional' })` on Message, Account, Attachment\n- **Required tenancy**: endpoints and persona routes are always tenant scoped\n- **Secrets are write-only**: never serialize credentials or endpoint.address to an app\n\n## Intake byte snapshots\n\n`EmailAccount.readIntakeMessage(providerLookup, maxBytes, imapIdentity?)` is the authorized host seam for\nopt-in ingestion. It copies full-message attachment bytes from the registered\nemail provider and fails when only attachment metadata is available; it never\nreads provider-supplied paths or advances sync checkpoints. Keep this path\nprovider-neutral and exclude arbitrary headers/credentials from its snapshot.\nIMAP lookup must include folder/UIDVALIDITY/UID verification; sender Message-ID\nis a lookup hint, never the durable delivery identity.\n",
|
|
12653
12747
|
"moduleDocs": [
|
|
12654
12748
|
{
|
|
12655
12749
|
"path": "agents/notification-outbox.md",
|
|
12656
12750
|
"module": "notification-outbox",
|
|
12657
|
-
"content": "# Notification email outbox (#4237)\n\n`NotificationOutboxService` turns a host event into at most one outbound email\nper recipient and delivers it from a crash-safe, leased outbox. It pairs with\n`UserNotificationService`: hosts that tell someone in-app and by email pass the\nsame `sourceRef` to both.\n\nSource: `src/models/NotificationDelivery.ts`,\n`src/services/NotificationOutboxService.ts`,\ntests `src/__tests__/notification-outbox*.test.ts`.\n\n## Enqueue\n\n`enqueueEmail({ tenantId, recipientUserId, sourceRef, kind, subject, text, path, notBefore? })`\nstores one `NotificationDelivery` (`notification_deliveries`) per\n`(tenant_id, recipient_user_id, channel, source_ref)`, the conflict identity. A\nrepeat (or a concurrent duplicate that loses the insert) returns the stored row\nuntouched with `created: false`. Encode each distinct event in `sourceRef`, e.g.\n`project-question:<id>:asked` and `project-question:<id>:reminder:<n>`.\n\nHost authorization, all checked before anything is stored:\n\n- `resolveRecipient({ tenantId, recipientUserId, channel })` returns the address\n the host allows for that user in that tenant, or `null` (denied with\n `NotificationOutboxDeniedError`, reason `recipient`). Callers never pass an\n address; the resolved one is snapshotted on the row.\n- `resolveSenderAccountId({ tenantId })` names an active `EmailAccount` owned by\n the same tenant (reason `sender` otherwise). Configuring that account is\n deployment setup; the outbox has no mail transport of its own.\n- With `principal`, enqueue, `listUncertain`, and `reconcile` require\n `messages.send` in the tenant (reason `permission`). Omit it only for trusted\n system code that authorizes itself.\n- `isSuppressed(...)` (optional) is the opt-out hook. A suppressed enqueue stores\n a `cancelled` row (so the event stays deduplicated); suppression at dispatch\n cancels before handoff. The package
|
|
12751
|
+
"content": "# Notification email outbox (#4237)\n\n`NotificationOutboxService` turns a host event into at most one outbound email\nper recipient and delivers it from a crash-safe, leased outbox. It pairs with\n`UserNotificationService`: hosts that tell someone in-app and by email pass the\nsame `sourceRef` to both.\n\nSource: `src/models/NotificationDelivery.ts`,\n`src/services/NotificationOutboxService.ts`,\ntests `src/__tests__/notification-outbox*.test.ts`.\n\n## Enqueue\n\n`enqueueEmail({ tenantId, recipientUserId, sourceRef, kind, subject, text, path, notBefore? })`\nstores one `NotificationDelivery` (`notification_deliveries`) per\n`(tenant_id, recipient_user_id, channel, source_ref)`, the conflict identity. A\nrepeat (or a concurrent duplicate that loses the insert) returns the stored row\nuntouched with `created: false`. Encode each distinct event in `sourceRef`, e.g.\n`project-question:<id>:asked` and `project-question:<id>:reminder:<n>`.\n\nHost authorization, all checked before anything is stored:\n\n- `resolveRecipient({ tenantId, recipientUserId, channel })` returns the address\n the host allows for that user in that tenant, or `null` (denied with\n `NotificationOutboxDeniedError`, reason `recipient`). Callers never pass an\n address; the resolved one is snapshotted on the row.\n- `resolveSenderAccountId({ tenantId })` names an active `EmailAccount` owned by\n the same tenant (reason `sender` otherwise). Configuring that account is\n deployment setup; the outbox has no mail transport of its own.\n- With `principal`, enqueue, `listUncertain`, and `reconcile` require\n `messages.send` in the tenant (reason `permission`). Omit it only for trusted\n system code that authorizes itself.\n- `isSuppressed(...)` (optional) is the opt-out hook. A suppressed enqueue stores\n a `cancelled` row (so the event stays deduplicated); suppression at dispatch\n cancels before handoff. The package's own preference store is the\n [opt-out ledger](#opt-out-ledger); wire it with `optOutSuppression`.\n\nDeep links: `path` must be host-relative (`/portal/...`); it is joined to\n`linkBaseUrl` (absolute http(s), no credentials/query/fragment, validated at\nconstruction) and must stay on that origin — `//host`, backslashes, whitespace,\nand control characters are refused (reason `link`). The link is stored on the\nrow, so a duplicate enqueue keeps the first link. Links carry no tokens: they\nlead to the host's normal authenticated route. Subjects must be one line.\n\n## Dispatch\n\n`dispatchDue({ tenantIds?, limit? })` (default 25, max 200) leases due rows —\n`pending` with `nextAttemptAt <= now`, and `sending` rows whose lease lapsed —\nfor the given tenants, or every tenant (discovered under `withSystemContext`)\nwhen `tenantIds` is omitted. Each row is processed under `withTenant(row.tenantId)`.\nRun it from a scheduled job; several workers may run it concurrently.\n\nEvery state change is `NotificationDelivery.transition()`: a compare-and-set on\n`stateVersion` (and the lease token for a lease holder) that writes a unique\n`transitionId` and re-reads the row to prove the write won. PostgreSQL and\nremote LibSQL use one conditional `UPDATE`; local embedded engines use the\nprocess write queue, like `Message.send()`'s claim.\n\nPer row:\n\n1. Acquire: `status = sending`, new `leaseToken`, `leaseExpiresAt = now + leaseMs`\n (default 5 min; must exceed the slowest provider call), `attempts + 1`.\n2. Create the single outbound `Email` and record `messageId`. Its id is\n `notificationDeliveryMessageId(deliveryId)` (a name-based uuid) and it is\n inserted insert-only, so a crashed or stale dispatcher finds or collides\n with the stored row instead of creating a second email; its correlation id\n is `notification-delivery:<deliveryId>`.\n3. Record `handedOffAt`, then call `transport.send({ delivery, message, idempotencyKey })`.\n4. Settle with the lease token: `accepted` → `sent`; `rejected` + `retryable`\n → `pending` after `retryDelayMs(attempts)` (default 1 min · 2ⁿ⁻¹, capped at\n 1 h) until `maxAttempts` (default 5), then `failed`; non-retryable → `failed`;\n `unknown` (including a thrown send) → `uncertain`. A holder whose lease was\n taken over gets `lease-lost` and writes nothing.\n\n## Provider idempotency and reconciliation policy\n\nDelivery is at-most-once once a provider may have seen the message.\n\n- A lapsed lease with no `handedOffAt` never reached the provider: it is resent.\n- Ambiguous provider failures are not rejections. `EmailSender` sets\n `MessageSendResult.outcomeUnknown` unless `isDefiniteSendRejection()` proves\n the send was refused (an SMTP reply code, an SDK auth/validation error, a\n send error that accepted no recipient, or a connection that was never made);\n `Message.send()` sets it when a sender throws. `Message` persists the marker\n in `metadata.sendOutcomeUnknown` with the `failed` status\n (`Message.isSendOutcomeUnknown()`), and `messageSendTransport` maps it to\n `unknown`, so the delivery becomes `uncertain` instead of being retried.\n- A lapsed lease after `handedOffAt` asks `transport.reconcile()`. The default\n `messageSendTransport` reads the canonical `Message.sendStatus`: `sent` →\n delivery `sent` (no resend), `failed` → normal retry (or `uncertain` when the\n failure carries the outcome-unknown marker), `draft`/`pending` → resend,\n anything else (e.g. still `sending`) → `uncertain`.\n- `uncertain` rows are never resent automatically. Operators list them with\n `listUncertain({ tenantId })` and settle them with\n `reconcile({ tenantId, deliveryId, outcome: 'sent' | 'failed' | 'retry', note? })`\n after checking the provider; `retry` accepts the risk of a duplicate. It\n first releases a message a dead sender left `sending`\n (`Message.abandonSendAttempt()`, a compare-and-set to `failed`) so the next\n lease can send it, and refuses when the message is recorded as `sent`.\n- A transport with `idempotent: true` declares that its provider deduplicates on\n `idempotencyKey` (stable per delivery); unknown outcomes are then retried\n instead of parked. SMTP and the current SDK email providers take no key, so\n `messageSendTransport` is not idempotent.\n\n## Transport seam\n\n`NotificationTransport { name; idempotent?; send(request); reconcile?(request) }`.\nThe default delivers through `Message.send()` → account → registered provider\nsender (import `@happyvertical/smrt-messages/providers/email` at server\nstartup). Tests inject a fake; nothing in the outbox reaches a provider SDK.\n\n## Opt-out ledger\n\n`NotificationOptOut` (`notification_opt_outs`, tenant-required, generated\nREST/MCP/CLI off) records one row per `(tenant_id, recipient_user_id, channel,\nscope)`: `channel` is `email`; `scope` is `*` (all notification email in the\ntenant) or a host key such as `project:<uuid>` (lowercase prefix, `:`, then\n`[A-Za-z0-9._:-]{1,200}`; malformed scopes throw). `optedOutAt` is set while\nopted out and cleared by an opt-in (the row stays as history); `updatedBy` is\nthe person's user id, or `system` for host code. It exists because\n`smrt-preferences` is owner-read only, while suppression runs where the recipient\nis not the caller (a manager enqueues, a worker dispatches).\n\n`NotificationOptOutService({ db })`:\n\n- `optOut({ tenantId, recipientUserId, channel, scope, principal? })` and\n `optIn(...)` are idempotent. With `principal: { userId, tenantId }` only\n `principal.userId === recipientUserId` may change the row, and only when\n `principal.tenantId` equals the request's tenant: a principal never acts in\n another tenant (`NotificationOptOutDeniedError`, reason `principal` or\n `tenant`). Without a principal the call is trusted host code that\n authenticated the recipient itself (e.g. a signed unsubscribe link, or a host\n route whose member belongs to a child tenant of the delivery tenant).\n- `isOptedOut({ tenantId, recipientUserId, channel, scope })` is the trusted\n suppression read, under `withTenant`; a `*` row matches every scope.\n- `listForRecipient({ tenantId, recipientUserId, principal })` returns only the\n recipient's own active opt-outs; any other principal is denied.\n\n```ts\nconst optOuts = new NotificationOptOutService({ db });\nnew NotificationOutboxService({\n // ...\n // `scopeOf` maps the host's own event to a scope; null = not opt-out-able.\n // A question's sourceRef names the question, not its project, so the host\n // resolves the project itself (scopeOf may be async).\n isSuppressed: optOutSuppression(optOuts, async (request) => {\n const projectId = await projectIdForSourceRef(request.tenantId, request.sourceRef);\n return projectId ? `project:${projectId}` : null;\n }),\n});\n```\n\nBecause the outbox re-checks at dispatch, an opt-out recorded after enqueue\ncancels the pending delivery before handoff. Tests:\n`src/__tests__/notification-opt-out*.test.ts`.\n\n## Schema\n\nNew tables `notification_opt_outs` (same rules) and `notification_deliveries` (tenant-required, generated REST/MCP/CLI\noff): consumers run `db:migrate`. `recipient_user_id` is a cross-package uuid\nref to `smrt-users:User`; `account_id`/`message_id` are FKs to `accounts` and\n`messages`.\n"
|
|
12658
12752
|
}
|
|
12659
12753
|
],
|
|
12660
12754
|
"recipes": [
|
|
@@ -5,10 +5,10 @@ description: "Unified multi-channel messaging with STI-based hierarchies"
|
|
|
5
5
|
status: "stable"
|
|
6
6
|
generated:
|
|
7
7
|
by: "smrt/scripts/generate-knowledge-okf.ts"
|
|
8
|
-
at: "2026-11-
|
|
8
|
+
at: "2026-11-06T03:05:34Z"
|
|
9
9
|
audience: "internal"
|
|
10
10
|
evidence_level: "source-inspected"
|
|
11
|
-
source_revision: "sha256:
|
|
11
|
+
source_revision: "sha256:99b6d6b6c81224d0127273516be7be6b62754883c9b6b9d3ee4d1bc6db3e9222"
|
|
12
12
|
sources:
|
|
13
13
|
- id: "manifest"
|
|
14
14
|
resource: "https://github.com/happyvertical/smrt/blob/main/packages/messages/package.json"
|
|
@@ -91,6 +91,8 @@ sources:
|
|
|
91
91
|
- `MessagingEndpointCollection` (collection `messagingendpoints`, extends `SmrtCollection`)
|
|
92
92
|
- `NotificationDelivery` (collection `notificationdeliveries`, extends `SmrtObject`)
|
|
93
93
|
- `NotificationDeliveryCollection` (collection `notificationdeliveries`, extends `SmrtCollection`)
|
|
94
|
+
- `NotificationOptOut` (collection `notificationoptouts`, extends `SmrtObject`)
|
|
95
|
+
- `NotificationOptOutCollection` (collection `notificationoptouts`, extends `SmrtCollection`)
|
|
94
96
|
- `PersonaMessageRoute` (collection `personamessageroutes`, extends `SmrtObject`)
|
|
95
97
|
- `PersonaMessageRouteCollection` (collection `personamessageroutes`, extends `SmrtCollection`)
|
|
96
98
|
- `SlackAccount` (collection `accounts`, extends `Account`, STI table)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-messages",
|
|
3
|
-
"version": "0.60.
|
|
3
|
+
"version": "0.60.11",
|
|
4
4
|
"smrtJsdoc": "strict",
|
|
5
5
|
"description": "Unified multi-channel messaging with STI-based hierarchies",
|
|
6
6
|
"type": "module",
|
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
"@happyvertical/files": "^0.111.5",
|
|
64
64
|
"@happyvertical/logger": "^0.111.5",
|
|
65
65
|
"@happyvertical/messages": "^0.111.5",
|
|
66
|
-
"@happyvertical/smrt-core": "0.60.
|
|
67
|
-
"@happyvertical/smrt-types": "0.60.
|
|
68
|
-
"@happyvertical/smrt-secrets": "0.60.
|
|
69
|
-
"@happyvertical/smrt-tenancy": "0.60.
|
|
70
|
-
"@happyvertical/smrt-ui": "0.60.
|
|
71
|
-
"@happyvertical/smrt-users": "0.60.
|
|
66
|
+
"@happyvertical/smrt-core": "0.60.11",
|
|
67
|
+
"@happyvertical/smrt-types": "0.60.11",
|
|
68
|
+
"@happyvertical/smrt-secrets": "0.60.11",
|
|
69
|
+
"@happyvertical/smrt-tenancy": "0.60.11",
|
|
70
|
+
"@happyvertical/smrt-ui": "0.60.11",
|
|
71
|
+
"@happyvertical/smrt-users": "0.60.11",
|
|
72
72
|
"@happyvertical/sql": "^0.111.5",
|
|
73
73
|
"@happyvertical/utils": "^0.111.5"
|
|
74
74
|
},
|
|
@@ -85,8 +85,8 @@
|
|
|
85
85
|
}
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
-
"@happyvertical/smrt-ledgers": "0.60.
|
|
89
|
-
"@happyvertical/smrt-vitest": "0.60.
|
|
88
|
+
"@happyvertical/smrt-ledgers": "0.60.11",
|
|
89
|
+
"@happyvertical/smrt-vitest": "0.60.11",
|
|
90
90
|
"@sveltejs/package": "^2.5.8",
|
|
91
91
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
92
92
|
"@types/node": "24.13.6",
|