@happyvertical/smrt-messages 0.60.9 → 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.
Files changed (44) hide show
  1. package/AGENTS.md +27 -0
  2. package/README.md +74 -0
  3. package/agents/notification-outbox.md +162 -0
  4. package/dist/chunks/{AccountCollection-CPsjabD-.js → AccountCollection-BGzfQG2I.js} +2 -2
  5. package/dist/chunks/{AccountCollection-CPsjabD-.js.map → AccountCollection-BGzfQG2I.js.map} +1 -1
  6. package/dist/chunks/{Email-C85SNWH0.js → Email-CBIrfFLm.js} +3 -3
  7. package/dist/chunks/{Email-C85SNWH0.js.map → Email-CBIrfFLm.js.map} +1 -1
  8. package/dist/chunks/{EmailFolder-Ia8OFxRn.js → EmailFolder-Cz2JU5Z1.js} +2 -2
  9. package/dist/chunks/{EmailFolder-Ia8OFxRn.js.map → EmailFolder-Cz2JU5Z1.js.map} +1 -1
  10. package/dist/chunks/{Message-x-bj-WcS.js → Message-DiO_FvAR.js} +52 -5
  11. package/dist/chunks/Message-DiO_FvAR.js.map +1 -0
  12. package/dist/chunks/{MessageCollection-BM4y2_mB.js → MessageCollection-uui10uhg.js} +2 -2
  13. package/dist/chunks/{MessageCollection-BM4y2_mB.js.map → MessageCollection-uui10uhg.js.map} +1 -1
  14. package/dist/chunks/__smrt-register__-DyTpUDMS.js +6 -0
  15. package/dist/chunks/__smrt-register__-DyTpUDMS.js.map +1 -0
  16. package/dist/collections/NotificationDeliveryCollection.d.ts +6 -0
  17. package/dist/collections/NotificationDeliveryCollection.d.ts.map +1 -0
  18. package/dist/collections/NotificationOptOutCollection.d.ts +6 -0
  19. package/dist/collections/NotificationOptOutCollection.d.ts.map +1 -0
  20. package/dist/index.d.ts +6 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +989 -38
  23. package/dist/index.js.map +1 -1
  24. package/dist/manifest.json +3870 -2495
  25. package/dist/models/Message.d.ts +15 -0
  26. package/dist/models/Message.d.ts.map +1 -1
  27. package/dist/models/NotificationDelivery.d.ts +128 -0
  28. package/dist/models/NotificationDelivery.d.ts.map +1 -0
  29. package/dist/models/NotificationOptOut.d.ts +44 -0
  30. package/dist/models/NotificationOptOut.d.ts.map +1 -0
  31. package/dist/senders/EmailSender.d.ts +9 -0
  32. package/dist/senders/EmailSender.d.ts.map +1 -1
  33. package/dist/services/NotificationOptOutService.d.ts +80 -0
  34. package/dist/services/NotificationOptOutService.d.ts.map +1 -0
  35. package/dist/services/NotificationOutboxService.d.ts +226 -0
  36. package/dist/services/NotificationOutboxService.d.ts.map +1 -0
  37. package/dist/smrt-knowledge.json +564 -9
  38. package/dist/smrt-knowledge.okf.md +157 -0
  39. package/dist/types.d.ts +6 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +17 -16
  42. package/dist/chunks/Message-x-bj-WcS.js.map +0 -1
  43. package/dist/chunks/__smrt-register__-B7iWfX8p.js +0 -6
  44. package/dist/chunks/__smrt-register__-B7iWfX8p.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { t as __exportAll } from "./chunks/rolldown-runtime-D7D4PA-g.js";
2
- import "./chunks/__smrt-register__-B7iWfX8p.js";
2
+ import "./chunks/__smrt-register__-DyTpUDMS.js";
3
3
  import { a as registerMessagingProvider, n as getMessagingProvider, o as ZulipSender, r as listMessagingProviders, s as TelegramSender, t as ensureBuiltinMessagingProvidersRegistered } from "./chunks/providers-4iLula-V.js";
4
- import { r as Account, t as AccountCollection } from "./chunks/AccountCollection-CPsjabD-.js";
5
- import { t as Message } from "./chunks/Message-x-bj-WcS.js";
6
- import { t as MessageCollection } from "./chunks/MessageCollection-BM4y2_mB.js";
7
- import { t as Email } from "./chunks/Email-C85SNWH0.js";
8
- import { t as EmailFolder } from "./chunks/EmailFolder-Ia8OFxRn.js";
4
+ import { r as Account, t as AccountCollection } from "./chunks/AccountCollection-BGzfQG2I.js";
5
+ import { t as Message } from "./chunks/Message-DiO_FvAR.js";
6
+ import { t as MessageCollection } from "./chunks/MessageCollection-uui10uhg.js";
7
+ import { t as Email } from "./chunks/Email-CBIrfFLm.js";
8
+ import { t as EmailFolder } from "./chunks/EmailFolder-Cz2JU5Z1.js";
9
9
  import { t as SlackSender } from "./chunks/SlackSender-CjSvwY1K.js";
10
10
  import { t as TweetSender } from "./chunks/TweetSender-CcLPNNi1.js";
11
- import { SmrtCollection, SmrtObject, SmrtRecipe, crossPackageRef, field, foreignKey, importOptionalDependency, isUniqueViolationError, smrt } from "@happyvertical/smrt-core";
11
+ import { SmrtCollection, SmrtObject, SmrtRecipe, crossPackageRef, field, foreignKey, importOptionalDependency, isUniqueViolationError, smrt, usesEmbeddedRevisionFallback, withEmbeddedWriteQueue } from "@happyvertical/smrt-core";
12
12
  import { registerPermissionDefinitions } from "@happyvertical/smrt-users";
13
- import { TenantScoped, queryGlobal, queryWithGlobals, tenantId, withTenant } from "@happyvertical/smrt-tenancy";
13
+ import { TenantScoped, queryGlobal, queryWithGlobals, tenantId, withSystemContext, withTenant } from "@happyvertical/smrt-tenancy";
14
14
  //#region src/permissions.ts
15
15
  var SEND_MESSAGES_PERMISSION = "messages.send";
16
16
  var MANAGE_MESSAGE_ROUTES_PERMISSION = "messages.manage-routes";
@@ -54,21 +54,21 @@ function ensureMessagingPermissionsRegistered() {
54
54
  }
55
55
  //#endregion
56
56
  //#region src/models/Attachment.ts
57
- var __defProp$12 = Object.defineProperty;
58
- var __getOwnPropDesc$12 = Object.getOwnPropertyDescriptor;
59
- var __defNormalProp$12 = (obj, key, value) => key in obj ? __defProp$12(obj, key, {
57
+ var __defProp$14 = Object.defineProperty;
58
+ var __getOwnPropDesc$14 = Object.getOwnPropertyDescriptor;
59
+ var __defNormalProp$14 = (obj, key, value) => key in obj ? __defProp$14(obj, key, {
60
60
  enumerable: true,
61
61
  configurable: true,
62
62
  writable: true,
63
63
  value
64
64
  }) : obj[key] = value;
65
- var __decorateClass$12 = (decorators, target, key, kind) => {
66
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$12(target, key) : target;
65
+ var __decorateClass$14 = (decorators, target, key, kind) => {
66
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$14(target, key) : target;
67
67
  for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
68
- if (kind && result) __defProp$12(target, key, result);
68
+ if (kind && result) __defProp$14(target, key, result);
69
69
  return result;
70
70
  };
71
- var __publicField$12 = (obj, key, value) => __defNormalProp$12(obj, typeof key !== "symbol" ? key + "" : key, value);
71
+ var __publicField$14 = (obj, key, value) => __defNormalProp$14(obj, typeof key !== "symbol" ? key + "" : key, value);
72
72
  function splitAbsolutePath(filePath) {
73
73
  const windows = /^[A-Za-z]:[\\/]/.test(filePath);
74
74
  if (!windows && !filePath.startsWith("/")) return null;
@@ -107,7 +107,7 @@ var Attachment = class extends SmrtObject {
107
107
  */
108
108
  async getMessage() {
109
109
  if (!this.messageId) return null;
110
- const { MessageCollection } = await import("./chunks/MessageCollection-BM4y2_mB.js").then((n) => n.n);
110
+ const { MessageCollection } = await import("./chunks/MessageCollection-uui10uhg.js").then((n) => n.n);
111
111
  return await (await MessageCollection.create(this.options)).get({ id: this.messageId });
112
112
  }
113
113
  /**
@@ -187,10 +187,10 @@ var Attachment = class extends SmrtObject {
187
187
  }
188
188
  }
189
189
  };
190
- __publicField$12(Attachment, "__smrtPackage__", "@happyvertical/smrt-messages");
191
- __decorateClass$12([tenantId({ nullable: true })], Attachment.prototype, "tenantId", 2);
192
- __decorateClass$12([foreignKey("Message")], Attachment.prototype, "messageId", 2);
193
- Attachment = __decorateClass$12([TenantScoped({ mode: "optional" }), smrt({
190
+ __publicField$14(Attachment, "__smrtPackage__", "@happyvertical/smrt-messages");
191
+ __decorateClass$14([tenantId({ nullable: true })], Attachment.prototype, "tenantId", 2);
192
+ __decorateClass$14([foreignKey("Message")], Attachment.prototype, "messageId", 2);
193
+ Attachment = __decorateClass$14([TenantScoped({ mode: "optional" }), smrt({
194
194
  api: { include: [
195
195
  "list",
196
196
  "get",
@@ -317,21 +317,21 @@ var AttachmentCollection = class extends SmrtCollection {
317
317
  };
318
318
  //#endregion
319
319
  //#region src/models/MessagingEndpoint.ts
320
- var __defProp$11 = Object.defineProperty;
321
- var __getOwnPropDesc$11 = Object.getOwnPropertyDescriptor;
322
- var __defNormalProp$11 = (obj, key, value) => key in obj ? __defProp$11(obj, key, {
320
+ var __defProp$13 = Object.defineProperty;
321
+ var __getOwnPropDesc$13 = Object.getOwnPropertyDescriptor;
322
+ var __defNormalProp$13 = (obj, key, value) => key in obj ? __defProp$13(obj, key, {
323
323
  enumerable: true,
324
324
  configurable: true,
325
325
  writable: true,
326
326
  value
327
327
  }) : obj[key] = value;
328
- var __decorateClass$11 = (decorators, target, key, kind) => {
329
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$11(target, key) : target;
328
+ var __decorateClass$13 = (decorators, target, key, kind) => {
329
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$13(target, key) : target;
330
330
  for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
331
- if (kind && result) __defProp$11(target, key, result);
331
+ if (kind && result) __defProp$13(target, key, result);
332
332
  return result;
333
333
  };
334
- var __publicField$11 = (obj, key, value) => __defNormalProp$11(obj, typeof key !== "symbol" ? key + "" : key, value);
334
+ var __publicField$13 = (obj, key, value) => __defNormalProp$13(obj, typeof key !== "symbol" ? key + "" : key, value);
335
335
  var MessagingEndpoint = class extends SmrtObject {
336
336
  tenantId = "";
337
337
  profileId = null;
@@ -376,16 +376,16 @@ var MessagingEndpoint = class extends SmrtObject {
376
376
  return value.length <= 4 ? "••••" : `\u2022\u2022\u2022\u2022${value.slice(-4)}`;
377
377
  }
378
378
  };
379
- __publicField$11(MessagingEndpoint, "__smrtPackage__", "@happyvertical/smrt-messages");
380
- __decorateClass$11([tenantId()], MessagingEndpoint.prototype, "tenantId", 2);
381
- __decorateClass$11([crossPackageRef("@happyvertical/smrt-profiles:Profile", { nullable: true })], MessagingEndpoint.prototype, "profileId", 2);
382
- __decorateClass$11([field({ required: true })], MessagingEndpoint.prototype, "label", 2);
383
- __decorateClass$11([field({ required: true })], MessagingEndpoint.prototype, "channel", 2);
384
- __decorateClass$11([field({
379
+ __publicField$13(MessagingEndpoint, "__smrtPackage__", "@happyvertical/smrt-messages");
380
+ __decorateClass$13([tenantId()], MessagingEndpoint.prototype, "tenantId", 2);
381
+ __decorateClass$13([crossPackageRef("@happyvertical/smrt-profiles:Profile", { nullable: true })], MessagingEndpoint.prototype, "profileId", 2);
382
+ __decorateClass$13([field({ required: true })], MessagingEndpoint.prototype, "label", 2);
383
+ __decorateClass$13([field({ required: true })], MessagingEndpoint.prototype, "channel", 2);
384
+ __decorateClass$13([field({
385
385
  required: true,
386
386
  sensitive: true
387
387
  })], MessagingEndpoint.prototype, "address", 2);
388
- MessagingEndpoint = __decorateClass$11([TenantScoped({ mode: "required" }), smrt({
388
+ MessagingEndpoint = __decorateClass$13([TenantScoped({ mode: "required" }), smrt({
389
389
  tableName: "messaging_endpoints",
390
390
  api: { include: ["list", "get"] },
391
391
  mcp: { include: ["list", "get"] },
@@ -403,6 +403,262 @@ var MessagingEndpointCollection = class extends SmrtCollection {
403
403
  static _itemClass = MessagingEndpoint;
404
404
  };
405
405
  //#endregion
406
+ //#region src/models/NotificationDelivery.ts
407
+ var __defProp$12 = Object.defineProperty;
408
+ var __getOwnPropDesc$12 = Object.getOwnPropertyDescriptor;
409
+ var __defNormalProp$12 = (obj, key, value) => key in obj ? __defProp$12(obj, key, {
410
+ enumerable: true,
411
+ configurable: true,
412
+ writable: true,
413
+ value
414
+ }) : obj[key] = value;
415
+ var __decorateClass$12 = (decorators, target, key, kind) => {
416
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$12(target, key) : target;
417
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
418
+ if (kind && result) __defProp$12(target, key, result);
419
+ return result;
420
+ };
421
+ var __publicField$12 = (obj, key, value) => __defNormalProp$12(obj, typeof key !== "symbol" ? key + "" : key, value);
422
+ var NOTIFICATION_DELIVERY_CHANNELS = ["email"];
423
+ var TRANSITION_COLUMNS = {
424
+ messageId: "message_id",
425
+ status: "status",
426
+ attempts: "attempts",
427
+ nextAttemptAt: "next_attempt_at",
428
+ leaseToken: "lease_token",
429
+ leaseExpiresAt: "lease_expires_at",
430
+ handedOffAt: "handed_off_at",
431
+ deliveredAt: "delivered_at",
432
+ providerMessageId: "provider_message_id",
433
+ lastError: "last_error"
434
+ };
435
+ var NotificationDelivery = class extends SmrtObject {
436
+ tenantId = "";
437
+ recipientUserId = "";
438
+ channel = "email";
439
+ sourceRef = "";
440
+ kind = "";
441
+ subject = "";
442
+ textBody = "";
443
+ href = "";
444
+ recipientAddress = "";
445
+ /** Display name snapshot from the host recipient resolver. */
446
+ recipientName = "";
447
+ accountId = "";
448
+ messageId = null;
449
+ status = "pending";
450
+ /** Compare-and-set counter; bumped by every transition. */
451
+ stateVersion = 0;
452
+ /** Unique id of the transition that wrote the current state. */
453
+ transitionId = "";
454
+ /** Leases acquired so far (provider handoffs are a subset). */
455
+ attempts = 0;
456
+ /** Lease budget before the delivery fails for good. */
457
+ maxAttempts = 5;
458
+ /** Earliest time a dispatcher may lease a `pending` row. */
459
+ nextAttemptAt = /* @__PURE__ */ new Date();
460
+ /** Proof of the current lease holder; cleared when the lease ends. */
461
+ leaseToken = null;
462
+ /** When the current lease lapses and the row becomes recoverable. */
463
+ leaseExpiresAt = null;
464
+ /** Set just before the current attempt reaches the provider. */
465
+ handedOffAt = null;
466
+ /** When the provider accepted the message. */
467
+ deliveredAt = null;
468
+ /** Provider message id, when the provider returns one. */
469
+ providerMessageId = "";
470
+ /** The latest failure or uncertainty reason. */
471
+ lastError = "";
472
+ constructor(options = {}) {
473
+ super(options);
474
+ const assign = (key) => {
475
+ if (options[key] !== void 0) this[key] = options[key];
476
+ };
477
+ for (const key of [
478
+ "tenantId",
479
+ "recipientUserId",
480
+ "channel",
481
+ "sourceRef",
482
+ "kind",
483
+ "subject",
484
+ "textBody",
485
+ "href",
486
+ "recipientAddress",
487
+ "recipientName",
488
+ "accountId",
489
+ "messageId",
490
+ "status",
491
+ "stateVersion",
492
+ "transitionId",
493
+ "attempts",
494
+ "maxAttempts",
495
+ "nextAttemptAt",
496
+ "leaseToken",
497
+ "leaseExpiresAt",
498
+ "handedOffAt",
499
+ "deliveredAt",
500
+ "providerMessageId",
501
+ "lastError"
502
+ ]) assign(key);
503
+ }
504
+ /** Whether no further automatic work will happen on this row. */
505
+ get isSettled() {
506
+ return this.status === "sent" || this.status === "failed" || this.status === "cancelled" || this.status === "uncertain";
507
+ }
508
+ /**
509
+ * Apply `changes` only if the stored row is still at `stateVersion` (and,
510
+ * when `leaseToken` is given, still held by that lease). Returns whether
511
+ * this caller won; on success the instance reflects the stored row.
512
+ *
513
+ * PostgreSQL and remote LibSQL evaluate the guard in one conditional
514
+ * UPDATE; local embedded engines use the process write queue, matching
515
+ * `Message.send()`'s claim. The durable row is re-read either way, and the
516
+ * write is recognised by its unique `transitionId`, because DuckDB's update
517
+ * result over-reports affected rows.
518
+ */
519
+ async transition(changes, expected = {}) {
520
+ if (!this.id) throw new Error("Cannot transition an unsaved delivery.");
521
+ const fromVersion = this.stateVersion;
522
+ const nextVersion = fromVersion + 1;
523
+ const transitionId = crypto.randomUUID();
524
+ const values = {
525
+ state_version: nextVersion,
526
+ transition_id: transitionId,
527
+ updated_at: /* @__PURE__ */ new Date()
528
+ };
529
+ for (const [key, value] of Object.entries(changes)) {
530
+ const column = TRANSITION_COLUMNS[key];
531
+ if (!column) throw new Error(`'${key}' is not a transition field.`);
532
+ values[column] = value;
533
+ }
534
+ const matches = (row) => row !== null && Number(row.state_version) === fromVersion && (expected.leaseToken === void 0 || row.lease_token === expected.leaseToken);
535
+ const useEmbeddedFallback = usesEmbeddedRevisionFallback(this.db);
536
+ await withEmbeddedWriteQueue(this.db, useEmbeddedFallback, async () => {
537
+ if (useEmbeddedFallback) {
538
+ const current = await this.getCanonicalPersistedRow({ id: this.id });
539
+ if (!matches(current)) return;
540
+ await this.db.upsert(this.tableName, ["id"], {
541
+ ...current,
542
+ ...values
543
+ });
544
+ return;
545
+ }
546
+ await this.db.update(this.tableName, {
547
+ id: this.id,
548
+ tenant_id: this.tenantId,
549
+ state_version: fromVersion,
550
+ ...expected.leaseToken === void 0 ? {} : { lease_token: expected.leaseToken }
551
+ }, values);
552
+ });
553
+ const verified = await this.getCanonicalPersistedRow({ id: this.id });
554
+ if (!verified || verified.transition_id !== transitionId) return false;
555
+ await this.completePersistedUpdate(verified);
556
+ return true;
557
+ }
558
+ };
559
+ __publicField$12(NotificationDelivery, "__smrtPackage__", "@happyvertical/smrt-messages");
560
+ __decorateClass$12([tenantId()], NotificationDelivery.prototype, "tenantId", 2);
561
+ __decorateClass$12([crossPackageRef("@happyvertical/smrt-users:User", { required: true })], NotificationDelivery.prototype, "recipientUserId", 2);
562
+ __decorateClass$12([field({ required: true })], NotificationDelivery.prototype, "channel", 2);
563
+ __decorateClass$12([field({ required: true })], NotificationDelivery.prototype, "sourceRef", 2);
564
+ __decorateClass$12([field({ required: true })], NotificationDelivery.prototype, "kind", 2);
565
+ __decorateClass$12([field({ required: true })], NotificationDelivery.prototype, "subject", 2);
566
+ __decorateClass$12([field({ required: true })], NotificationDelivery.prototype, "textBody", 2);
567
+ __decorateClass$12([field({ required: true })], NotificationDelivery.prototype, "href", 2);
568
+ __decorateClass$12([field({ required: true })], NotificationDelivery.prototype, "recipientAddress", 2);
569
+ __decorateClass$12([foreignKey("Account")], NotificationDelivery.prototype, "accountId", 2);
570
+ __decorateClass$12([foreignKey("Message")], NotificationDelivery.prototype, "messageId", 2);
571
+ NotificationDelivery = __decorateClass$12([TenantScoped({ mode: "required" }), smrt({
572
+ tableName: "notification_deliveries",
573
+ api: false,
574
+ mcp: false,
575
+ cli: false,
576
+ conflictColumns: [
577
+ "tenant_id",
578
+ "recipient_user_id",
579
+ "channel",
580
+ "source_ref"
581
+ ],
582
+ indexes: [{
583
+ name: "notification_deliveries_due_idx",
584
+ columns: ["status", "nextAttemptAt"]
585
+ }, {
586
+ name: "notification_deliveries_lease_idx",
587
+ columns: ["status", "leaseExpiresAt"]
588
+ }]
589
+ })], NotificationDelivery);
590
+ //#endregion
591
+ //#region src/collections/NotificationDeliveryCollection.ts
592
+ var NotificationDeliveryCollection = class extends SmrtCollection {
593
+ static _itemClass = NotificationDelivery;
594
+ };
595
+ //#endregion
596
+ //#region src/models/NotificationOptOut.ts
597
+ var __defProp$11 = Object.defineProperty;
598
+ var __getOwnPropDesc$11 = Object.getOwnPropertyDescriptor;
599
+ var __defNormalProp$11 = (obj, key, value) => key in obj ? __defProp$11(obj, key, {
600
+ enumerable: true,
601
+ configurable: true,
602
+ writable: true,
603
+ value
604
+ }) : obj[key] = value;
605
+ var __decorateClass$11 = (decorators, target, key, kind) => {
606
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$11(target, key) : target;
607
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
608
+ if (kind && result) __defProp$11(target, key, result);
609
+ return result;
610
+ };
611
+ var __publicField$11 = (obj, key, value) => __defNormalProp$11(obj, typeof key !== "symbol" ? key + "" : key, value);
612
+ var NOTIFICATION_OPT_OUT_SCOPE_PATTERN = /^(?:\*|[a-z][a-z0-9-]*:[A-Za-z0-9._:-]{1,200})$/;
613
+ var NOTIFICATION_OPT_OUT_ALL_SCOPE = "*";
614
+ function isValidNotificationOptOutScope(scope) {
615
+ return typeof scope === "string" && NOTIFICATION_OPT_OUT_SCOPE_PATTERN.test(scope);
616
+ }
617
+ var NotificationOptOut = class extends SmrtObject {
618
+ tenantId = "";
619
+ recipientUserId = "";
620
+ channel = "email";
621
+ scope = "";
622
+ /** When the opt-out took effect; null once the person opted back in. */
623
+ optedOutAt = null;
624
+ /** Who last changed the row: the person's user id, or `system` for host code. */
625
+ updatedBy = "";
626
+ constructor(options = {}) {
627
+ super(options);
628
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
629
+ if (options.recipientUserId !== void 0) this.recipientUserId = options.recipientUserId;
630
+ if (options.channel !== void 0) this.channel = options.channel;
631
+ if (options.scope !== void 0) this.scope = options.scope;
632
+ if (options.optedOutAt !== void 0) this.optedOutAt = options.optedOutAt;
633
+ if (options.updatedBy !== void 0) this.updatedBy = options.updatedBy;
634
+ }
635
+ get isOptedOut() {
636
+ return this.optedOutAt !== null;
637
+ }
638
+ };
639
+ __publicField$11(NotificationOptOut, "__smrtPackage__", "@happyvertical/smrt-messages");
640
+ __decorateClass$11([tenantId()], NotificationOptOut.prototype, "tenantId", 2);
641
+ __decorateClass$11([crossPackageRef("@happyvertical/smrt-users:User", { required: true })], NotificationOptOut.prototype, "recipientUserId", 2);
642
+ __decorateClass$11([field({ required: true })], NotificationOptOut.prototype, "channel", 2);
643
+ __decorateClass$11([field({ required: true })], NotificationOptOut.prototype, "scope", 2);
644
+ NotificationOptOut = __decorateClass$11([TenantScoped({ mode: "required" }), smrt({
645
+ tableName: "notification_opt_outs",
646
+ api: false,
647
+ mcp: false,
648
+ cli: false,
649
+ conflictColumns: [
650
+ "tenant_id",
651
+ "recipient_user_id",
652
+ "channel",
653
+ "scope"
654
+ ]
655
+ })], NotificationOptOut);
656
+ //#endregion
657
+ //#region src/collections/NotificationOptOutCollection.ts
658
+ var NotificationOptOutCollection = class extends SmrtCollection {
659
+ static _itemClass = NotificationOptOut;
660
+ };
661
+ //#endregion
406
662
  //#region src/models/PersonaMessageRoute.ts
407
663
  var __defProp$10 = Object.defineProperty;
408
664
  var __getOwnPropDesc$10 = Object.getOwnPropertyDescriptor;
@@ -688,7 +944,7 @@ var EmailAccount = class extends Account {
688
944
  for (const folderName of foldersToSync) try {
689
945
  let folder = await folderCollection.getByPath(accountId, folderName);
690
946
  if (!folder) {
691
- const { EmailFolder } = await import("./chunks/EmailFolder-Ia8OFxRn.js").then((n) => n.n);
947
+ const { EmailFolder } = await import("./chunks/EmailFolder-Cz2JU5Z1.js").then((n) => n.n);
692
948
  folder = new EmailFolder({
693
949
  ...this.childOptions(),
694
950
  accountId,
@@ -713,7 +969,7 @@ var EmailAccount = class extends Account {
713
969
  result.messagesSkipped++;
714
970
  continue;
715
971
  }
716
- const { Email } = await import("./chunks/Email-C85SNWH0.js").then((n) => n.n);
972
+ const { Email } = await import("./chunks/Email-CBIrfFLm.js").then((n) => n.n);
717
973
  let email = existingEmail;
718
974
  if (!email) {
719
975
  email = new Email({
@@ -1924,7 +2180,10 @@ ZulipAccount = __decorateClass([smrt({
1924
2180
  })], ZulipAccount);
1925
2181
  //#endregion
1926
2182
  //#region src/senders/EmailSender.ts
1927
- var EmailSender_exports = /* @__PURE__ */ __exportAll({ EmailSender: () => EmailSender });
2183
+ var EmailSender_exports = /* @__PURE__ */ __exportAll({
2184
+ EmailSender: () => EmailSender,
2185
+ isDefiniteSendRejection: () => isDefiniteSendRejection
2186
+ });
1928
2187
  var EmailSender = class {
1929
2188
  providerType = "email";
1930
2189
  client;
@@ -1987,6 +2246,7 @@ var EmailSender = class {
1987
2246
  return {
1988
2247
  success: false,
1989
2248
  error: error instanceof Error ? error.message : String(error),
2249
+ ...isDefiniteSendRejection(error) ? {} : { outcomeUnknown: true },
1990
2250
  sentAt: /* @__PURE__ */ new Date()
1991
2251
  };
1992
2252
  }
@@ -2033,6 +2293,31 @@ var EmailSender = class {
2033
2293
  return resolved;
2034
2294
  }
2035
2295
  };
2296
+ var DEFINITE_EMAIL_ERROR_CODES = /* @__PURE__ */ new Set([
2297
+ "AUTHENTICATION_ERROR",
2298
+ "AUTHORIZATION_ERROR",
2299
+ "INVALID_MESSAGE",
2300
+ "ATTACHMENT_ERROR"
2301
+ ]);
2302
+ var NEVER_CONNECTED_CODES = /* @__PURE__ */ new Set([
2303
+ "ECONNREFUSED",
2304
+ "ENOTFOUND",
2305
+ "EHOSTUNREACH",
2306
+ "EAI_AGAIN"
2307
+ ]);
2308
+ function isDefiniteSendRejection(error) {
2309
+ if (!error || typeof error !== "object") return false;
2310
+ const candidates = [error, error.cause].filter((value) => !!value && typeof value === "object");
2311
+ for (const candidate of candidates) {
2312
+ if (typeof candidate.responseCode === "number") return true;
2313
+ if (typeof candidate.code === "string") {
2314
+ if (DEFINITE_EMAIL_ERROR_CODES.has(candidate.code)) return true;
2315
+ if (NEVER_CONNECTED_CODES.has(candidate.code)) return true;
2316
+ }
2317
+ }
2318
+ const sendError = error;
2319
+ return sendError.code === "SEND_ERROR" && Array.isArray(sendError.accepted) && sendError.accepted.length === 0;
2320
+ }
2036
2321
  //#endregion
2037
2322
  //#region src/services/MessagingSettingsService.ts
2038
2323
  var MessagingSettingsService = class {
@@ -2231,6 +2516,672 @@ var MessagingSettingsService = class {
2231
2516
  }
2232
2517
  };
2233
2518
  //#endregion
2519
+ //#region src/services/NotificationOptOutService.ts
2520
+ var NotificationOptOutDeniedError = class extends Error {
2521
+ constructor(reason, message) {
2522
+ super(message);
2523
+ this.reason = reason;
2524
+ this.name = "NotificationOptOutDeniedError";
2525
+ }
2526
+ reason;
2527
+ };
2528
+ function requireText$2(value, name) {
2529
+ const text = typeof value === "string" ? value.trim() : "";
2530
+ if (!text) throw new Error(`A notification opt-out ${name} is required.`);
2531
+ return text;
2532
+ }
2533
+ function validate(key) {
2534
+ const channel = key.channel;
2535
+ if (!NOTIFICATION_DELIVERY_CHANNELS.includes(channel)) throw new Error(`Unknown notification channel '${String(channel)}'.`);
2536
+ if (!isValidNotificationOptOutScope(key.scope)) throw new Error(`Invalid notification opt-out scope '${String(key.scope)}': use '*' or a key like 'project:<id>'.`);
2537
+ return {
2538
+ tenantId: requireText$2(key.tenantId, "tenant"),
2539
+ recipientUserId: requireText$2(key.recipientUserId, "recipient"),
2540
+ channel,
2541
+ scope: key.scope
2542
+ };
2543
+ }
2544
+ function authorize(principal, tenantId, recipientUserId) {
2545
+ if (!principal) return "system";
2546
+ if (!principal.tenantId || principal.tenantId !== tenantId) throw new NotificationOptOutDeniedError("tenant", "Principal does not belong to this tenant.");
2547
+ if (!principal.userId || principal.userId !== recipientUserId) throw new NotificationOptOutDeniedError("principal", "Only the recipient may change or list their notification opt-outs.");
2548
+ return principal.userId;
2549
+ }
2550
+ var NotificationOptOutService = class {
2551
+ constructor(options) {
2552
+ this.options = options;
2553
+ }
2554
+ options;
2555
+ collection() {
2556
+ return NotificationOptOutCollection.create({ db: this.options.db });
2557
+ }
2558
+ /** Opt the recipient out of `scope`; a repeat leaves the row untouched. */
2559
+ async optOut(change) {
2560
+ return this.change(change, true);
2561
+ }
2562
+ /** Clear an opt-out; a repeat, or no row at all, is a no-op. */
2563
+ async optIn(change) {
2564
+ return this.change(change, false);
2565
+ }
2566
+ /**
2567
+ * Whether the recipient has opted out of `scope` (a `*` row matches every
2568
+ * scope). Trusted read: no principal, always under the tenant's scope.
2569
+ */
2570
+ async isOptedOut(key) {
2571
+ const { tenantId, recipientUserId, channel, scope } = validate(key);
2572
+ const scopes = scope === "*" ? [scope] : [scope, "*"];
2573
+ return withTenant({ tenantId }, async () => {
2574
+ return (await (await this.collection()).list({ where: {
2575
+ tenantId,
2576
+ recipientUserId,
2577
+ channel,
2578
+ "scope in": scopes
2579
+ } })).some((row) => row.optedOutAt !== null);
2580
+ });
2581
+ }
2582
+ /** The recipient's own active opt-outs, in one tenant. */
2583
+ async listForRecipient(request) {
2584
+ const tenantId = requireText$2(request.tenantId, "tenant");
2585
+ const recipientUserId = requireText$2(request.recipientUserId, "recipient");
2586
+ if (!request.principal) throw new NotificationOptOutDeniedError("principal", "A principal is required to list opt-outs.");
2587
+ authorize(request.principal, tenantId, recipientUserId);
2588
+ return withTenant({ tenantId }, async () => {
2589
+ return (await (await this.collection()).list({
2590
+ where: {
2591
+ tenantId,
2592
+ recipientUserId
2593
+ },
2594
+ orderBy: "scope ASC"
2595
+ })).filter((row) => row.optedOutAt !== null);
2596
+ });
2597
+ }
2598
+ async change(change, optedOut) {
2599
+ const key = validate(change);
2600
+ const updatedBy = authorize(change.principal, key.tenantId, key.recipientUserId);
2601
+ const { tenantId, recipientUserId, channel, scope } = key;
2602
+ return withTenant({ tenantId }, async () => {
2603
+ const rows = await this.collection();
2604
+ const find = async () => (await rows.list({
2605
+ where: {
2606
+ tenantId,
2607
+ recipientUserId,
2608
+ channel,
2609
+ scope
2610
+ },
2611
+ limit: 1
2612
+ }))[0];
2613
+ const apply = async (row) => {
2614
+ if (row.isOptedOut === optedOut) return row;
2615
+ row.optedOutAt = optedOut ? /* @__PURE__ */ new Date() : null;
2616
+ row.updatedBy = updatedBy;
2617
+ await row.save();
2618
+ return row;
2619
+ };
2620
+ const existing = await find();
2621
+ if (existing) return apply(existing);
2622
+ if (!optedOut) return new NotificationOptOut({
2623
+ tenantId,
2624
+ recipientUserId,
2625
+ channel,
2626
+ scope,
2627
+ optedOutAt: null,
2628
+ updatedBy
2629
+ });
2630
+ try {
2631
+ return await rows.create({
2632
+ _insertOnly: true,
2633
+ tenantId,
2634
+ recipientUserId,
2635
+ channel,
2636
+ scope,
2637
+ optedOutAt: /* @__PURE__ */ new Date(),
2638
+ updatedBy
2639
+ });
2640
+ } catch (error) {
2641
+ if (!isUniqueViolationError(error)) throw error;
2642
+ const winner = await find();
2643
+ if (!winner) throw error;
2644
+ return apply(winner);
2645
+ }
2646
+ });
2647
+ }
2648
+ };
2649
+ function optOutSuppression(service, scopeOf) {
2650
+ return async (request) => {
2651
+ const scope = await scopeOf(request);
2652
+ if (scope === null || scope === void 0) return false;
2653
+ return service.isOptedOut({
2654
+ tenantId: request.tenantId,
2655
+ recipientUserId: request.recipientUserId,
2656
+ channel: request.channel,
2657
+ scope
2658
+ });
2659
+ };
2660
+ }
2661
+ //#endregion
2662
+ //#region src/services/NotificationOutboxService.ts
2663
+ var messageSendTransport = {
2664
+ name: "message-send",
2665
+ idempotent: false,
2666
+ async send({ message }) {
2667
+ const result = await message.send();
2668
+ if (result.success) return {
2669
+ status: "accepted",
2670
+ ...result.providerMessageId ? { providerMessageId: result.providerMessageId } : {}
2671
+ };
2672
+ if (result.outcomeUnknown || message.sendStatus === "sending" || message.sendStatus === "sent") return {
2673
+ status: "unknown",
2674
+ error: result.error ?? "The provider did not confirm the outcome."
2675
+ };
2676
+ return {
2677
+ status: "rejected",
2678
+ error: result.error ?? "Send failed",
2679
+ retryable: true
2680
+ };
2681
+ },
2682
+ async reconcile({ message }) {
2683
+ const messages = await MessageCollection.create({ db: message.db });
2684
+ const stored = message.id ? await messages.get({ id: message.id }) : null;
2685
+ switch (stored?.sendStatus) {
2686
+ case "sent": return { status: "accepted" };
2687
+ case "failed":
2688
+ if (stored.isSendOutcomeUnknown()) return {
2689
+ status: "unknown",
2690
+ error: stored.sendError || "The provider did not confirm the outcome."
2691
+ };
2692
+ return {
2693
+ status: "rejected",
2694
+ error: stored.sendError || "Send failed",
2695
+ retryable: true
2696
+ };
2697
+ case "draft":
2698
+ case "pending":
2699
+ case "scheduled": return { status: "not-sent" };
2700
+ default: return {
2701
+ status: "unknown",
2702
+ error: "The provider handoff did not record an outcome."
2703
+ };
2704
+ }
2705
+ }
2706
+ };
2707
+ var NotificationOutboxDeniedError = class extends Error {
2708
+ constructor(reason, message) {
2709
+ super(message);
2710
+ this.reason = reason;
2711
+ this.name = "NotificationOutboxDeniedError";
2712
+ }
2713
+ reason;
2714
+ };
2715
+ var UUID_PATTERN$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2716
+ var DEFAULT_LEASE_MS = 5 * 6e4;
2717
+ var DEFAULT_MAX_ATTEMPTS = 5;
2718
+ var DEFAULT_LIMIT$1 = 25;
2719
+ var MAX_LIMIT$1 = 200;
2720
+ function defaultRetryDelayMs(attempts) {
2721
+ return Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 60 * 6e4);
2722
+ }
2723
+ function requireText$1(value, name) {
2724
+ const text = value?.trim();
2725
+ if (!text) throw new Error(`A notification ${name} is required.`);
2726
+ return text;
2727
+ }
2728
+ function errorText(error) {
2729
+ return error instanceof Error ? error.message : String(error);
2730
+ }
2731
+ async function notificationDeliveryMessageId(deliveryId) {
2732
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`smrt-messages:notification-delivery:${deliveryId}`));
2733
+ const bytes = new Uint8Array(digest).slice(0, 16);
2734
+ bytes[6] = (bytes[6] ?? 0) & 15 | 80;
2735
+ bytes[8] = (bytes[8] ?? 0) & 63 | 128;
2736
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0"));
2737
+ return [
2738
+ hex.slice(0, 4),
2739
+ hex.slice(4, 6),
2740
+ hex.slice(6, 8),
2741
+ hex.slice(8, 10),
2742
+ hex.slice(10, 16)
2743
+ ].map((group) => group.join("")).join("-");
2744
+ }
2745
+ function notificationDeliveryCorrelationId(deliveryId) {
2746
+ return `notification-delivery:${deliveryId}`;
2747
+ }
2748
+ function buildNotificationLink(baseUrl, path) {
2749
+ let base;
2750
+ try {
2751
+ base = new URL(baseUrl);
2752
+ } catch {
2753
+ throw new Error("Notification link base URL must be an absolute URL.");
2754
+ }
2755
+ if (base.protocol !== "https:" && base.protocol !== "http:") throw new Error("Notification link base URL must use http or https.");
2756
+ if (base.username || base.password || base.search || base.hash) throw new Error("Notification link base URL must not carry credentials, a query, or a fragment.");
2757
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || /[\\\s]/u.test(path) || /[\u0000-\u001f\u007f]/u.test(path)) throw new NotificationOutboxDeniedError("link", "Notification links must be a host-relative path such as /inbox/123.");
2758
+ const prefix = base.pathname.replace(/\/+$/u, "");
2759
+ const link = new URL(`${prefix}${path}`, base.origin);
2760
+ if (link.origin !== base.origin) throw new NotificationOutboxDeniedError("link", "Notification links must stay on the configured origin.");
2761
+ return link.toString();
2762
+ }
2763
+ var NotificationOutboxService = class {
2764
+ constructor(options) {
2765
+ this.options = options;
2766
+ buildNotificationLink(options.linkBaseUrl, "/");
2767
+ this.transport = options.transport ?? messageSendTransport;
2768
+ this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
2769
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
2770
+ this.retryDelayMs = options.retryDelayMs ?? defaultRetryDelayMs;
2771
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
2772
+ if (!Number.isInteger(this.leaseMs) || this.leaseMs < 1e3) throw new Error("Notification lease must be at least 1000 ms.");
2773
+ if (!Number.isInteger(this.maxAttempts) || this.maxAttempts < 1) throw new Error("Notification maxAttempts must be a positive integer.");
2774
+ }
2775
+ options;
2776
+ transport;
2777
+ leaseMs;
2778
+ maxAttempts;
2779
+ retryDelayMs;
2780
+ now;
2781
+ collection() {
2782
+ return NotificationDeliveryCollection.create({ db: this.options.db });
2783
+ }
2784
+ authorize(tenantId) {
2785
+ if (!this.options.principal) return;
2786
+ try {
2787
+ assertMessagingPermission(this.options.principal, tenantId, SEND_MESSAGES_PERMISSION);
2788
+ } catch (error) {
2789
+ throw new NotificationOutboxDeniedError("permission", errorText(error));
2790
+ }
2791
+ }
2792
+ /** Store one email delivery for the event unless `sourceRef` already has one. */
2793
+ async enqueueEmail(input) {
2794
+ const tenantId = requireText$1(input.tenantId, "tenant");
2795
+ const recipientUserId = requireText$1(input.recipientUserId, "recipient");
2796
+ const sourceRef = requireText$1(input.sourceRef, "sourceRef");
2797
+ const kind = requireText$1(input.kind, "kind");
2798
+ const subject = requireText$1(input.subject, "subject");
2799
+ const text = requireText$1(input.text, "text");
2800
+ if (/[\r\n]/u.test(subject)) throw new Error("A notification subject must be a single line.");
2801
+ const channel = "email";
2802
+ this.authorize(tenantId);
2803
+ const href = buildNotificationLink(this.options.linkBaseUrl, input.path);
2804
+ return withTenant({ tenantId }, async () => {
2805
+ const deliveries = await this.collection();
2806
+ const findExisting = async () => (await deliveries.list({
2807
+ where: {
2808
+ tenantId,
2809
+ recipientUserId,
2810
+ channel,
2811
+ sourceRef
2812
+ },
2813
+ limit: 1
2814
+ }))[0];
2815
+ const existing = await findExisting();
2816
+ if (existing) return {
2817
+ delivery: existing,
2818
+ created: false
2819
+ };
2820
+ const recipient = await this.options.resolveRecipient({
2821
+ tenantId,
2822
+ recipientUserId,
2823
+ channel
2824
+ });
2825
+ const address = recipient?.address?.trim();
2826
+ if (!address || /[\s,;<>]/u.test(address) || !address.includes("@")) throw new NotificationOutboxDeniedError("recipient", "The host did not authorize an email recipient for this user.");
2827
+ const accountId = await this.requireSenderAccount(tenantId);
2828
+ const suppressed = await this.isSuppressed({
2829
+ tenantId,
2830
+ recipientUserId,
2831
+ channel,
2832
+ kind,
2833
+ sourceRef
2834
+ });
2835
+ try {
2836
+ return {
2837
+ delivery: await deliveries.create({
2838
+ _insertOnly: true,
2839
+ tenantId,
2840
+ recipientUserId,
2841
+ channel,
2842
+ sourceRef,
2843
+ kind,
2844
+ subject,
2845
+ textBody: text,
2846
+ href,
2847
+ recipientAddress: address,
2848
+ recipientName: recipient?.name?.trim() ?? "",
2849
+ accountId,
2850
+ messageId: null,
2851
+ status: suppressed ? "cancelled" : "pending",
2852
+ stateVersion: 0,
2853
+ transitionId: "",
2854
+ attempts: 0,
2855
+ maxAttempts: this.maxAttempts,
2856
+ nextAttemptAt: input.notBefore ?? this.now(),
2857
+ leaseToken: null,
2858
+ leaseExpiresAt: null,
2859
+ handedOffAt: null,
2860
+ deliveredAt: null,
2861
+ providerMessageId: "",
2862
+ lastError: suppressed ? "Suppressed by recipient preference." : ""
2863
+ }),
2864
+ created: true
2865
+ };
2866
+ } catch (error) {
2867
+ if (!isUniqueViolationError(error)) throw error;
2868
+ const winner = await findExisting();
2869
+ if (!winner) throw error;
2870
+ return {
2871
+ delivery: winner,
2872
+ created: false
2873
+ };
2874
+ }
2875
+ });
2876
+ }
2877
+ /** Lease and deliver due rows; returns what happened to each leased row. */
2878
+ async dispatchDue(options = {}) {
2879
+ const limit = Math.min(options.limit ?? DEFAULT_LIMIT$1, MAX_LIMIT$1);
2880
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("Notification dispatch limit must be a positive integer.");
2881
+ const candidates = await this.findDue(options.tenantIds, limit);
2882
+ const report = {
2883
+ leased: 0,
2884
+ outcomes: []
2885
+ };
2886
+ for (const candidate of candidates) {
2887
+ if (report.leased >= limit) break;
2888
+ const outcome = await withTenant({ tenantId: candidate.tenantId }, () => this.dispatchOne(candidate));
2889
+ if (!outcome) continue;
2890
+ report.leased += 1;
2891
+ report.outcomes.push({
2892
+ deliveryId: candidate.id,
2893
+ tenantId: candidate.tenantId,
2894
+ outcome
2895
+ });
2896
+ }
2897
+ return report;
2898
+ }
2899
+ /** Deliveries waiting for a reconciliation decision, oldest first. */
2900
+ async listUncertain(options) {
2901
+ const tenantId = requireText$1(options.tenantId, "tenant");
2902
+ this.authorize(tenantId);
2903
+ return withTenant({ tenantId }, async () => (await this.collection()).list({
2904
+ where: {
2905
+ tenantId,
2906
+ status: "uncertain"
2907
+ },
2908
+ orderBy: "handedOffAt ASC",
2909
+ limit: Math.min(options.limit ?? DEFAULT_LIMIT$1, MAX_LIMIT$1)
2910
+ }));
2911
+ }
2912
+ /**
2913
+ * Settle an `uncertain` delivery after checking the provider out of band:
2914
+ * `sent` (it arrived), `failed` (give up), or `retry` (send again,
2915
+ * accepting the risk of a duplicate; a message left claimed by a dead
2916
+ * sender is released first). Returns false when the row is not uncertain
2917
+ * any more, or for `retry` when the message is recorded as sent.
2918
+ */
2919
+ async reconcile(input) {
2920
+ const tenantId = requireText$1(input.tenantId, "tenant");
2921
+ this.authorize(tenantId);
2922
+ if (!UUID_PATTERN$1.test(input.deliveryId ?? "")) return false;
2923
+ return withTenant({ tenantId }, async () => {
2924
+ const delivery = (await (await this.collection()).list({
2925
+ where: {
2926
+ tenantId,
2927
+ id: input.deliveryId
2928
+ },
2929
+ limit: 1
2930
+ }))[0];
2931
+ if (delivery?.status !== "uncertain") return false;
2932
+ const note = input.note?.trim();
2933
+ const now = this.now();
2934
+ switch (input.outcome) {
2935
+ case "sent": return delivery.transition({
2936
+ status: "sent",
2937
+ deliveredAt: now,
2938
+ ...note ? { lastError: note } : {}
2939
+ });
2940
+ case "failed": return delivery.transition({
2941
+ status: "failed",
2942
+ lastError: note || delivery.lastError
2943
+ });
2944
+ case "retry": {
2945
+ const message = delivery.messageId ? await this.loadMessage(delivery.messageId) : null;
2946
+ if (message?.sendStatus === "sent") return false;
2947
+ if (message?.sendStatus === "sending" && !await message.abandonSendAttempt(note || "Released for an operator-approved retry.")) return false;
2948
+ return delivery.transition({
2949
+ status: "pending",
2950
+ nextAttemptAt: now,
2951
+ handedOffAt: null,
2952
+ attempts: Math.min(delivery.attempts, delivery.maxAttempts - 1),
2953
+ ...note ? { lastError: note } : {}
2954
+ });
2955
+ }
2956
+ default: throw new Error(`Unknown reconcile outcome '${input.outcome}'.`);
2957
+ }
2958
+ });
2959
+ }
2960
+ async requireSenderAccount(tenantId) {
2961
+ const accountId = (await this.options.resolveSenderAccountId({ tenantId }))?.trim();
2962
+ const account = accountId ? await (await AccountCollection.create({ db: this.options.db })).get({ id: accountId }) : null;
2963
+ if (!account || !(account instanceof EmailAccount) || account.tenantId !== tenantId || !account.isActive) throw new NotificationOutboxDeniedError("sender", "No active email sender account is configured for this tenant.");
2964
+ return account.id;
2965
+ }
2966
+ async isSuppressed(request) {
2967
+ return Boolean(await this.options.isSuppressed?.(request));
2968
+ }
2969
+ async findDue(tenantIds, limit) {
2970
+ const now = this.now();
2971
+ const read = async (tenantFilter) => {
2972
+ const deliveries = await this.collection();
2973
+ const [due, expired] = await Promise.all([deliveries.list({
2974
+ where: {
2975
+ ...tenantFilter,
2976
+ status: "pending",
2977
+ "nextAttemptAt <=": now
2978
+ },
2979
+ orderBy: "nextAttemptAt ASC",
2980
+ limit
2981
+ }), deliveries.list({
2982
+ where: {
2983
+ ...tenantFilter,
2984
+ status: "sending",
2985
+ "leaseExpiresAt <=": now
2986
+ },
2987
+ orderBy: "leaseExpiresAt ASC",
2988
+ limit
2989
+ })]);
2990
+ return [...expired, ...due];
2991
+ };
2992
+ return (tenantIds === void 0 ? await withSystemContext(() => read({})) : (await Promise.all([...new Set(tenantIds.filter(Boolean))].map((tenantId) => withTenant({ tenantId }, () => read({ tenantId }))))).flat()).slice(0, limit * 2);
2993
+ }
2994
+ /** Lease one candidate and carry it to an outcome; null when not leased. */
2995
+ async dispatchOne(candidate) {
2996
+ const delivery = (await (await this.collection()).list({
2997
+ where: {
2998
+ tenantId: candidate.tenantId,
2999
+ id: candidate.id
3000
+ },
3001
+ limit: 1
3002
+ }))[0];
3003
+ if (!delivery) return null;
3004
+ const now = this.now();
3005
+ const recovering = delivery.status === "sending" && delivery.leaseExpiresAt !== null && delivery.leaseExpiresAt.getTime() <= now.getTime();
3006
+ const due = delivery.status === "pending" && delivery.nextAttemptAt.getTime() <= now.getTime();
3007
+ if (!recovering && !due) return null;
3008
+ const leaseToken = crypto.randomUUID();
3009
+ const leaseExpiresAt = new Date(now.getTime() + this.leaseMs);
3010
+ const priorHandoff = recovering && delivery.handedOffAt !== null;
3011
+ if (!await delivery.transition({
3012
+ status: "sending",
3013
+ leaseToken,
3014
+ leaseExpiresAt,
3015
+ attempts: priorHandoff ? delivery.attempts : delivery.attempts + 1,
3016
+ ...priorHandoff ? {} : { handedOffAt: null }
3017
+ })) return null;
3018
+ try {
3019
+ if (priorHandoff) {
3020
+ const resolved = await this.recoverHandoff(delivery, leaseToken);
3021
+ if (resolved) return resolved;
3022
+ }
3023
+ if (await this.isSuppressed({
3024
+ tenantId: delivery.tenantId,
3025
+ recipientUserId: delivery.recipientUserId,
3026
+ channel: delivery.channel,
3027
+ kind: delivery.kind,
3028
+ sourceRef: delivery.sourceRef
3029
+ })) return this.settle(delivery, leaseToken, {
3030
+ status: "cancelled",
3031
+ lastError: "Suppressed by recipient preference."
3032
+ });
3033
+ const message = await this.ensureMessage(delivery, leaseToken);
3034
+ if (!message) return "lease-lost";
3035
+ if (!await delivery.transition({ handedOffAt: this.now() }, { leaseToken })) return "lease-lost";
3036
+ let outcome;
3037
+ try {
3038
+ outcome = await this.transport.send({
3039
+ delivery,
3040
+ message,
3041
+ idempotencyKey: notificationDeliveryCorrelationId(delivery.id)
3042
+ });
3043
+ } catch (error) {
3044
+ outcome = {
3045
+ status: "unknown",
3046
+ error: errorText(error)
3047
+ };
3048
+ }
3049
+ return this.applyOutcome(delivery, leaseToken, outcome);
3050
+ } catch (error) {
3051
+ if (delivery.handedOffAt !== null) throw error;
3052
+ return this.applyOutcome(delivery, leaseToken, {
3053
+ status: "rejected",
3054
+ error: errorText(error),
3055
+ retryable: true
3056
+ });
3057
+ }
3058
+ }
3059
+ async recoverHandoff(delivery, leaseToken) {
3060
+ const message = delivery.messageId ? await this.loadMessage(delivery.messageId) : null;
3061
+ let known = {
3062
+ status: "unknown",
3063
+ error: "Lease lapsed after the provider handoff; outcome unknown."
3064
+ };
3065
+ if (message && this.transport.reconcile) try {
3066
+ known = await this.transport.reconcile({
3067
+ delivery,
3068
+ message,
3069
+ idempotencyKey: notificationDeliveryCorrelationId(delivery.id)
3070
+ });
3071
+ } catch (error) {
3072
+ known = {
3073
+ status: "unknown",
3074
+ error: errorText(error)
3075
+ };
3076
+ }
3077
+ if (known.status === "not-sent") {
3078
+ if (!await delivery.transition({
3079
+ handedOffAt: null,
3080
+ attempts: delivery.attempts + 1
3081
+ }, { leaseToken })) return "lease-lost";
3082
+ return null;
3083
+ }
3084
+ if (known.status === "unknown" && this.transport.idempotent) {
3085
+ if (!await delivery.transition({
3086
+ handedOffAt: null,
3087
+ attempts: delivery.attempts + 1
3088
+ }, { leaseToken })) return "lease-lost";
3089
+ return null;
3090
+ }
3091
+ return this.applyOutcome(delivery, leaseToken, known);
3092
+ }
3093
+ async applyOutcome(delivery, leaseToken, outcome) {
3094
+ if (outcome.status === "accepted") return this.settle(delivery, leaseToken, {
3095
+ status: "sent",
3096
+ deliveredAt: this.now(),
3097
+ providerMessageId: outcome.providerMessageId ?? "",
3098
+ lastError: ""
3099
+ });
3100
+ if (outcome.status === "unknown") {
3101
+ if (this.transport.idempotent && delivery.attempts < delivery.maxAttempts) return this.scheduleRetry(delivery, leaseToken, outcome.error);
3102
+ return this.settle(delivery, leaseToken, {
3103
+ status: "uncertain",
3104
+ lastError: outcome.error
3105
+ });
3106
+ }
3107
+ if (outcome.retryable && delivery.attempts < delivery.maxAttempts) return this.scheduleRetry(delivery, leaseToken, outcome.error);
3108
+ return this.settle(delivery, leaseToken, {
3109
+ status: "failed",
3110
+ lastError: outcome.error
3111
+ });
3112
+ }
3113
+ async scheduleRetry(delivery, leaseToken, error) {
3114
+ const delay = this.retryDelayMs(delivery.attempts);
3115
+ return await this.endLease(delivery, leaseToken, {
3116
+ status: "pending",
3117
+ nextAttemptAt: new Date(this.now().getTime() + Math.max(0, delay)),
3118
+ handedOffAt: null,
3119
+ lastError: error
3120
+ }) ? "retry-scheduled" : "lease-lost";
3121
+ }
3122
+ /** End the lease in a terminal status; reports lease loss instead of overwriting. */
3123
+ async settle(delivery, leaseToken, changes) {
3124
+ return await this.endLease(delivery, leaseToken, changes) ? changes.status : "lease-lost";
3125
+ }
3126
+ endLease(delivery, leaseToken, changes) {
3127
+ return delivery.transition({
3128
+ ...changes,
3129
+ leaseToken: null,
3130
+ leaseExpiresAt: null
3131
+ }, { leaseToken });
3132
+ }
3133
+ async loadMessage(messageId) {
3134
+ return await (await MessageCollection.create({ db: this.options.db })).get({ id: messageId }) ?? null;
3135
+ }
3136
+ /**
3137
+ * The delivery's single outbound `Email`, created under the lease. Its id
3138
+ * is derived from the delivery id and inserted insert-only, so a stale
3139
+ * dispatcher that resumes after its lease was taken over hits the stored
3140
+ * row (a unique violation) instead of creating a second email.
3141
+ */
3142
+ async ensureMessage(delivery, leaseToken) {
3143
+ if (delivery.messageId) {
3144
+ const message2 = await this.loadMessage(delivery.messageId);
3145
+ if (message2) return message2;
3146
+ }
3147
+ const messageId = await notificationDeliveryMessageId(delivery.id);
3148
+ let message = await this.loadMessage(messageId);
3149
+ if (!message) {
3150
+ const body = `${delivery.textBody}
3151
+
3152
+ ${delivery.href}
3153
+ `;
3154
+ const emails = await EmailCollection.create({ db: this.options.db });
3155
+ try {
3156
+ await emails.create({
3157
+ _insertOnly: true,
3158
+ id: messageId,
3159
+ tenantId: delivery.tenantId,
3160
+ accountId: delivery.accountId,
3161
+ correlationId: notificationDeliveryCorrelationId(delivery.id),
3162
+ subject: delivery.subject,
3163
+ body,
3164
+ textBody: body,
3165
+ toAddresses: JSON.stringify([{
3166
+ address: delivery.recipientAddress,
3167
+ ...delivery.recipientName ? { name: delivery.recipientName } : {}
3168
+ }]),
3169
+ sendStatus: "pending",
3170
+ maxRetries: 0
3171
+ });
3172
+ } catch (error) {
3173
+ if (!isUniqueViolationError(error)) throw error;
3174
+ }
3175
+ message = await this.loadMessage(messageId);
3176
+ if (!message) throw new Error("The outbound email could not be stored.");
3177
+ }
3178
+ if (delivery.messageId !== message.id) {
3179
+ if (!await delivery.transition({ messageId: message.id }, { leaseToken })) return null;
3180
+ }
3181
+ return message;
3182
+ }
3183
+ };
3184
+ //#endregion
2234
3185
  //#region src/services/PersonaMessagingService.ts
2235
3186
  var PersonaMessagingService = class {
2236
3187
  constructor(options) {
@@ -2759,6 +3710,6 @@ var SlackRecipe = class extends SmrtRecipe {
2759
3710
  ensureBuiltinMessagingProvidersRegistered();
2760
3711
  ensureMessagingPermissionsRegistered();
2761
3712
  //#endregion
2762
- export { Account, AccountCollection, Attachment, AttachmentCollection, Blacklist, BlacklistCollection, Email, EmailAccount, EmailAccountCollection, EmailAttachment, EmailAttachmentCollection, EmailCollection, EmailFolder, EmailFolderCollection, EmailSender, MANAGE_MESSAGE_CREDENTIALS_PERMISSION, MANAGE_MESSAGE_ROUTES_PERMISSION, MESSAGING_PERMISSION_DEFINITIONS, MailboxRecipe, Message, MessageCollection, MessagingEndpoint, MessagingEndpointCollection, MessagingSettingsService, NotificationsRecipe, PersonaMessageRoute, PersonaMessageRouteCollection, PersonaMessagingService, SEND_MESSAGES_PERMISSION, SlackAccount, SlackMessage, SlackRecipe, SlackSender, TelegramAccount, TelegramSender, Tweet, TweetSender, TwitterAccount, USER_NOTIFICATION_SEVERITIES, UserNotification, UserNotificationCollection, UserNotificationService, Whitelist, WhitelistCollection, ZulipAccount, ZulipSender, AttachmentCollection_exports as a, assertMessagingPermission, ensureBuiltinMessagingProvidersRegistered, ensureMessagingPermissionsRegistered, getMessagingProvider, MessagingEndpointCollection_exports as i, listMessagingProviders, messagingPrincipalFromPermissions, EmailCollection_exports as n, EmailAccountCollection_exports as r, registerMessagingProvider, EmailFolderCollection_exports as t };
3713
+ export { Account, AccountCollection, Attachment, AttachmentCollection, Blacklist, BlacklistCollection, Email, EmailAccount, EmailAccountCollection, EmailAttachment, EmailAttachmentCollection, EmailCollection, EmailFolder, EmailFolderCollection, EmailSender, MANAGE_MESSAGE_CREDENTIALS_PERMISSION, MANAGE_MESSAGE_ROUTES_PERMISSION, MESSAGING_PERMISSION_DEFINITIONS, MailboxRecipe, Message, MessageCollection, MessagingEndpoint, MessagingEndpointCollection, MessagingSettingsService, NOTIFICATION_DELIVERY_CHANNELS, NOTIFICATION_OPT_OUT_ALL_SCOPE, NOTIFICATION_OPT_OUT_SCOPE_PATTERN, NotificationDelivery, NotificationDeliveryCollection, NotificationOptOut, NotificationOptOutCollection, NotificationOptOutDeniedError, NotificationOptOutService, NotificationOutboxDeniedError, NotificationOutboxService, NotificationsRecipe, PersonaMessageRoute, PersonaMessageRouteCollection, PersonaMessagingService, SEND_MESSAGES_PERMISSION, SlackAccount, SlackMessage, SlackRecipe, SlackSender, TelegramAccount, TelegramSender, Tweet, TweetSender, TwitterAccount, USER_NOTIFICATION_SEVERITIES, UserNotification, UserNotificationCollection, UserNotificationService, Whitelist, WhitelistCollection, ZulipAccount, ZulipSender, AttachmentCollection_exports as a, assertMessagingPermission, buildNotificationLink, ensureBuiltinMessagingProvidersRegistered, ensureMessagingPermissionsRegistered, getMessagingProvider, MessagingEndpointCollection_exports as i, isValidNotificationOptOutScope, listMessagingProviders, messageSendTransport, messagingPrincipalFromPermissions, EmailCollection_exports as n, notificationDeliveryCorrelationId, notificationDeliveryMessageId, optOutSuppression, EmailAccountCollection_exports as r, registerMessagingProvider, EmailFolderCollection_exports as t };
2763
3714
 
2764
3715
  //# sourceMappingURL=index.js.map