@softure-ai/mailing 0.1.5 → 0.1.7

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 (91) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +85 -23
  3. package/dist/cli/run.d.ts +1 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +56 -22
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +48 -8
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/contract.js +2 -0
  10. package/dist/contract.js.map +1 -1
  11. package/dist/index.d.ts +12 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/messages/en.d.ts +2 -0
  16. package/dist/messages/en.d.ts.map +1 -1
  17. package/dist/messages/en.js +2 -0
  18. package/dist/messages/en.js.map +1 -1
  19. package/dist/messages/index.d.ts +4 -0
  20. package/dist/messages/index.d.ts.map +1 -1
  21. package/dist/messages/pl.d.ts.map +1 -1
  22. package/dist/messages/pl.js +2 -0
  23. package/dist/messages/pl.js.map +1 -1
  24. package/dist/next/actions.d.ts.map +1 -1
  25. package/dist/next/actions.js +6 -6
  26. package/dist/next/actions.js.map +1 -1
  27. package/dist/next/context.js +2 -2
  28. package/dist/next/context.js.map +1 -1
  29. package/dist/next/pages.d.ts.map +1 -1
  30. package/dist/next/pages.js +8 -6
  31. package/dist/next/pages.js.map +1 -1
  32. package/dist/next/route.d.ts +2 -1
  33. package/dist/next/route.d.ts.map +1 -1
  34. package/dist/next/route.js +6 -4
  35. package/dist/next/route.js.map +1 -1
  36. package/dist/options.d.ts +15 -1
  37. package/dist/options.d.ts.map +1 -1
  38. package/dist/options.js +32 -1
  39. package/dist/options.js.map +1 -1
  40. package/dist/providers/resend.d.ts.map +1 -1
  41. package/dist/providers/resend.js +12 -6
  42. package/dist/providers/resend.js.map +1 -1
  43. package/dist/schema.d.ts +17 -0
  44. package/dist/schema.d.ts.map +1 -1
  45. package/dist/schema.js +2 -0
  46. package/dist/schema.js.map +1 -1
  47. package/dist/server/campaigns.d.ts +30 -4
  48. package/dist/server/campaigns.d.ts.map +1 -1
  49. package/dist/server/campaigns.js +67 -12
  50. package/dist/server/campaigns.js.map +1 -1
  51. package/dist/server/deliveries.d.ts +30 -2
  52. package/dist/server/deliveries.d.ts.map +1 -1
  53. package/dist/server/deliveries.js +58 -23
  54. package/dist/server/deliveries.js.map +1 -1
  55. package/dist/server/index.d.ts +3 -3
  56. package/dist/server/index.d.ts.map +1 -1
  57. package/dist/server/index.js +2 -2
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/send-mail.d.ts +3 -1
  60. package/dist/server/send-mail.d.ts.map +1 -1
  61. package/dist/server/send-mail.js +18 -6
  62. package/dist/server/send-mail.js.map +1 -1
  63. package/dist/server/suppressions.d.ts +8 -6
  64. package/dist/server/suppressions.d.ts.map +1 -1
  65. package/dist/server/suppressions.js +23 -7
  66. package/dist/server/suppressions.js.map +1 -1
  67. package/dist/server/unsubscribe-link.d.ts +23 -0
  68. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  69. package/dist/server/unsubscribe-link.js +30 -1
  70. package/dist/server/unsubscribe-link.js.map +1 -1
  71. package/migrations/0003_add_delivery_provider_status.sql +13 -0
  72. package/module.json +1 -1
  73. package/package.json +6 -2
  74. package/src/cli/run.ts +72 -27
  75. package/src/contract.ts +49 -6
  76. package/src/index.ts +8 -3
  77. package/src/messages/en.ts +2 -0
  78. package/src/messages/pl.ts +2 -0
  79. package/src/next/actions.ts +5 -6
  80. package/src/next/context.ts +2 -2
  81. package/src/next/pages.tsx +10 -7
  82. package/src/next/route.ts +6 -4
  83. package/src/options.ts +36 -2
  84. package/src/providers/resend.ts +11 -7
  85. package/src/schema.ts +2 -0
  86. package/src/server/campaigns.ts +95 -17
  87. package/src/server/deliveries.ts +89 -29
  88. package/src/server/index.ts +8 -0
  89. package/src/server/send-mail.ts +30 -6
  90. package/src/server/suppressions.ts +23 -8
  91. package/src/server/unsubscribe-link.ts +41 -1
@@ -4,7 +4,7 @@
4
4
  // envelope or the sender. List mail (any kind but `transactional`) also gets a signed unsubscribe
5
5
  // link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
6
6
  import { err, ok } from "@softure-ai/core";
7
- import { TRANSACTIONAL_KIND } from "../contract.js";
7
+ import { TRANSACTIONAL_KIND, } from "../contract.js";
8
8
  import { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders } from "./list-mail.js";
9
9
  import { getMailingModule, getMailingOptions } from "./options.js";
10
10
  import { isSuppressed } from "./suppressions.js";
@@ -13,7 +13,9 @@ import { validateMail } from "./validate-mail.js";
13
13
  /**
14
14
  * Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
15
15
  * `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
16
- * same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry).
16
+ * same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry),
17
+ * `provider_refused` / `quota_exceeded` (the account cannot send: stop, fix or wait, then retry). A
18
+ * failure the provider answered with an HTTP status carries it as `httpStatus`.
17
19
  * Throws only when the module is not enabled, or for list mail without `context.db`.
18
20
  */
19
21
  export async function sendMail(context, mail, options = {}) {
@@ -37,10 +39,16 @@ export async function sendMail(context, mail, options = {}) {
37
39
  if (outcome.status === "sent") {
38
40
  return ok({ id: outcome.id, provider: provider.name });
39
41
  }
40
- const code = `mailing.${outcome.status}`;
42
+ const code = FAILURE_CODES[outcome.status];
41
43
  logFailure(provider, code, { status: outcome.httpStatus });
42
- return err(code);
44
+ return outcome.httpStatus === undefined ? err(code) : { ...err(code), httpStatus: outcome.httpStatus };
43
45
  }
46
+ const FAILURE_CODES = {
47
+ rejected: "mailing.rejected",
48
+ unavailable: "mailing.unavailable",
49
+ refused: "mailing.provider_refused",
50
+ quota_exceeded: "mailing.quota_exceeded",
51
+ };
44
52
  /**
45
53
  * A list mail with its footer and headers, or why it must not go out. Fails closed: without the
46
54
  * secret, or when the suppression list cannot be read, nothing is sent.
@@ -109,11 +117,15 @@ function readOutcome(outcome) {
109
117
  if (status === "sent") {
110
118
  return typeof id === "string" && id !== "" ? { status, id } : { status: "unavailable" };
111
119
  }
112
- if (status === "rejected" || status === "unavailable") {
113
- return typeof httpStatus === "number" ? { status, httpStatus } : { status };
120
+ if (typeof status === "string" && Object.hasOwn(FAILURE_CODES, status)) {
121
+ const failure = status;
122
+ return isHttpStatus(httpStatus) ? { status: failure, httpStatus } : { status: failure };
114
123
  }
115
124
  return { status: "unavailable" };
116
125
  }
126
+ function isHttpStatus(value) {
127
+ return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599;
128
+ }
117
129
  /**
118
130
  * The module's only log line. Deliberately takes nothing but the provider, the code, a status,
119
131
  * field names and a fixed cause: anything added "for diagnosis" is where an address leaks first.
@@ -1 +1 @@
1
- {"version":3,"file":"send-mail.js","sourceRoot":"","sources":["../../src/server/send-mail.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"send-mail.js","sourceRoot":"","sources":["../../src/server/send-mail.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { type ModuleContext, type Result } from "@softure-ai/core";
2
2
  import type { Queryable } from "@softure-ai/db";
3
3
  import type { SuppressionSource, UnsubscribeErrorCode } from "../contract.js";
4
- import { type Env, type UnsubscribeToken } from "./unsubscribe-link.js";
4
+ import { type Env, type UnsubscribeLink, type UnsubscribeToken } from "./unsubscribe-link.js";
5
5
  export type SuppressionContext = ModuleContext<Queryable>;
6
6
  /** Whether `address` unsubscribed. Throws on a database failure: callers decide how to fail. */
7
7
  export declare function isSuppressed(ctx: Pick<SuppressionContext, "db">, address: string): Promise<boolean>;
@@ -18,10 +18,12 @@ export declare function suppressRecipient(ctx: SuppressionContext, address: stri
18
18
  */
19
19
  export declare function liftSuppression(ctx: Pick<SuppressionContext, "db">, address: string): Promise<boolean>;
20
20
  /**
21
- * Unsubscribes the recipient of a signed link and runs `onUnsubscribed` in the same transaction,
22
- * also when the recipient had already unsubscribed (a retried one-click heals a missed hook). The
23
- * signature is checked before the database is touched; a link that does not verify is
24
- * `mailing.invalid_link`. Throws on a database failure or when the hook throws (nothing is stored).
21
+ * Unsubscribes the recipient of a link and runs `onUnsubscribed` in the same transaction, also when
22
+ * the recipient had already unsubscribed (a retried one-click heals a missed hook). A signed link
23
+ * (or a bare token) is checked against the secrets before the database is touched; a legacy link
24
+ * goes to the app's `legacyUnsubscribe.verify`, which names the address. A link that does not
25
+ * verify is `mailing.invalid_link`. Throws on a database failure, or when `verify` or the hook
26
+ * throws (nothing is stored).
25
27
  */
26
- export declare function unsubscribe(ctx: SuppressionContext, token: UnsubscribeToken | null, source: Exclude<SuppressionSource, "operator">, env?: Env): Promise<Result<undefined, UnsubscribeErrorCode>>;
28
+ export declare function unsubscribe(ctx: SuppressionContext, link: UnsubscribeLink | UnsubscribeToken | null, source: Exclude<SuppressionSource, "operator">, env?: Env): Promise<Result<undefined, UnsubscribeErrorCode>>;
27
29
  //# sourceMappingURL=suppressions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"suppressions.d.ts","sourceRoot":"","sources":["../../src/server/suppressions.ts"],"names":[],"mappings":"AAKA,OAAO,EAAW,KAAK,aAAa,EAAE,KAAK,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAG9E,OAAO,EAAmE,KAAK,GAAG,EAAE,KAAK,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAEzI,MAAM,MAAM,kBAAkB,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;AAE1D,gGAAgG;AAChG,wBAAsB,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,kBAAkB,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOzG;AAOD;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,iBAA8B,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvI;AAKD;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,IAAI,CAAC,kBAAkB,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAM5G;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,kBAAkB,EACvB,KAAK,EAAE,gBAAgB,GAAG,IAAI,EAC9B,MAAM,EAAE,OAAO,CAAC,iBAAiB,EAAE,UAAU,CAAC,EAC9C,GAAG,GAAE,GAAiB,GACrB,OAAO,CAAC,MAAM,CAAC,SAAS,EAAE,oBAAoB,CAAC,CAAC,CAUlD"}
1
+ {"version":3,"file":"suppressions.d.ts","sourceRoot":"","sources":["../../src/server/suppressions.ts"],"names":[],"mappings":"AAKA,OAAO,EAAW,KAAK,aAAa,EAAE,KAAK,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAG9E,OAAO,EAAmE,KAAK,GAAG,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE/J,MAAM,MAAM,kBAAkB,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;AAE1D,gGAAgG;AAChG,wBAAsB,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,kBAAkB,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOzG;AAOD;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,iBAA8B,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvI;AAKD;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,IAAI,CAAC,kBAAkB,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAM5G;AAED;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,kBAAkB,EACvB,IAAI,EAAE,eAAe,GAAG,gBAAgB,GAAG,IAAI,EAC/C,MAAM,EAAE,OAAO,CAAC,iBAAiB,EAAE,UAAU,CAAC,EAC9C,GAAG,GAAE,GAAiB,GACrB,OAAO,CAAC,MAAM,CAAC,SAAS,EAAE,oBAAoB,CAAC,CAAC,CAUlD"}
@@ -44,16 +44,18 @@ export async function liftSuppression(ctx, address) {
44
44
  return lifted.length > 0;
45
45
  }
46
46
  /**
47
- * Unsubscribes the recipient of a signed link and runs `onUnsubscribed` in the same transaction,
48
- * also when the recipient had already unsubscribed (a retried one-click heals a missed hook). The
49
- * signature is checked before the database is touched; a link that does not verify is
50
- * `mailing.invalid_link`. Throws on a database failure or when the hook throws (nothing is stored).
47
+ * Unsubscribes the recipient of a link and runs `onUnsubscribed` in the same transaction, also when
48
+ * the recipient had already unsubscribed (a retried one-click heals a missed hook). A signed link
49
+ * (or a bare token) is checked against the secrets before the database is touched; a legacy link
50
+ * goes to the app's `legacyUnsubscribe.verify`, which names the address. A link that does not
51
+ * verify is `mailing.invalid_link`. Throws on a database failure, or when `verify` or the hook
52
+ * throws (nothing is stored).
51
53
  */
52
- export async function unsubscribe(ctx, token, source, env = process.env) {
53
- if (token === null || !verifyUnsubscribeToken(token, readUnsubscribeSecrets(env)))
54
+ export async function unsubscribe(ctx, link, source, env = process.env) {
55
+ const recipientKey = await verifyLink(ctx, link, env);
56
+ if (recipientKey === null)
54
57
  return err("mailing.invalid_link");
55
58
  const { onUnsubscribed } = getMailingOptions(ctx.config);
56
- const { recipientKey } = token;
57
59
  await ctx.db.transaction(async (tx) => {
58
60
  const txCtx = { ...ctx, db: tx };
59
61
  await recordSuppression(txCtx, recipientKey, source);
@@ -61,4 +63,18 @@ export async function unsubscribe(ctx, token, source, env = process.env) {
61
63
  });
62
64
  return ok();
63
65
  }
66
+ /** The recipient key a link proves, or `null` when it proves none. */
67
+ async function verifyLink(ctx, link, env) {
68
+ if (link === null)
69
+ return null;
70
+ if (!("scheme" in link) || link.scheme === "signed") {
71
+ const token = "scheme" in link ? link.token : link;
72
+ return verifyUnsubscribeToken(token, readUnsubscribeSecrets(env)) ? token.recipientKey : null;
73
+ }
74
+ const legacy = getMailingOptions(ctx.config).legacyUnsubscribe;
75
+ if (legacy === undefined)
76
+ return null;
77
+ const address = await legacy.verify(link.values, ctx);
78
+ return typeof address === "string" && address.trim() !== "" ? getRecipientKey(address) : null;
79
+ }
64
80
  //# sourceMappingURL=suppressions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"suppressions.js","sourceRoot":"","sources":["../../src/server/suppressions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"suppressions.js","sourceRoot":"","sources":["../../src/server/suppressions.ts"],"names":[],"mappings":"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"}
@@ -44,6 +44,29 @@ export declare function readUnsubscribeSecrets(env?: Env): UnsubscribeSecrets;
44
44
  export declare function verifyUnsubscribeToken(token: UnsubscribeToken, secrets: UnsubscribeSecrets): boolean;
45
45
  /** The two links for `address`, on the app's origin and the module's routes. */
46
46
  export declare function buildUnsubscribeLinks(config: SoftureConfig, address: string, secret: string): UnsubscribeLinks;
47
+ /** A legacy link's value longer than this makes the link invalid before the app's `verify` sees it. */
48
+ export declare const MAX_LEGACY_VALUE_LENGTH = 512;
49
+ /** The link a person followed: the module's signed one, or one the app sent before it adopted the module. */
50
+ export type UnsubscribeLink = {
51
+ readonly scheme: "signed";
52
+ readonly token: UnsubscribeToken;
53
+ } | {
54
+ readonly scheme: "legacy";
55
+ readonly values: Readonly<Record<string, string>>;
56
+ };
57
+ /** Reads one query or form value; `URLSearchParams` and `FormData` both fit. */
58
+ export interface LinkParams {
59
+ get(name: string): unknown;
60
+ }
61
+ /**
62
+ * The link in a query or a form. A link with the recipient parameter (`r`) is a signed link, whatever else it
63
+ * carries, so a crafted URL cannot route a signed link to the app's legacy check. Without it, and with
64
+ * `mailing({ legacyUnsubscribe })`, a link that has every legacy parameter (non-empty, at most 512 characters) is
65
+ * a legacy link. Anything else is `null`. Checks no signature: `unsubscribe` does.
66
+ */
67
+ export declare function readUnsubscribeLink(params: LinkParams, config: SoftureConfig): UnsubscribeLink | null;
68
+ /** The query (or form) parameters that carry `link`, to send it back to the page. */
69
+ export declare function getUnsubscribeLinkParams(link: UnsubscribeLink): Record<string, string>;
47
70
  /** The token in a link's query (or a form), when both parameters are present once. */
48
71
  export declare function readUnsubscribeToken(params: {
49
72
  get(name: string): unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"unsubscribe-link.d.ts","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,yCAAyC;AACzC,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AACnE,sEAAsE;AACtE,eAAO,MAAM,+BAA+B,wCAAwC,CAAC;AACrF,kFAAkF;AAClF,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD,0CAA0C;AAC1C,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,eAAO,MAAM,eAAe,MAAM,CAAC;AAWnC,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAE/D,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,2DAA2D;AAC3D,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,8FAA8F;AAC9F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wCAAwC;AACxC,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,yFAAyF;AACzF,wBAAgB,sBAAsB,CAAC,GAAG,GAAE,GAAiB,GAAG,kBAAkB,CAEjF;AAMD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAWpG;AAED,gFAAgF;AAChF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAQ9G;AAED,sFAAsF;AACtF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE;IAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,GAAG,gBAAgB,GAAG,IAAI,CAIpG"}
1
+ {"version":3,"file":"unsubscribe-link.d.ts","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,yCAAyC;AACzC,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AACnE,sEAAsE;AACtE,eAAO,MAAM,+BAA+B,wCAAwC,CAAC;AACrF,kFAAkF;AAClF,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD,0CAA0C;AAC1C,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,eAAO,MAAM,eAAe,MAAM,CAAC;AAWnC,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAE/D,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,2DAA2D;AAC3D,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,8FAA8F;AAC9F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wCAAwC;AACxC,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,yFAAyF;AACzF,wBAAgB,sBAAsB,CAAC,GAAG,GAAE,GAAiB,GAAG,kBAAkB,CAEjF;AAMD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAWpG;AAED,gFAAgF;AAChF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAQ9G;AAED,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAE3C,6GAA6G;AAC7G,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAA;CAAE,GAC/D;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAAE,CAAC;AAErF,gFAAgF;AAChF,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;CAC5B;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,aAAa,GAAG,eAAe,GAAG,IAAI,CAcrG;AAED,qFAAqF;AACrF,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAEtF;AAED,sFAAsF;AACtF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE;IAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,GAAG,gBAAgB,GAAG,IAAI,CAIpG"}
@@ -4,7 +4,7 @@
4
4
  // during a rotation. The whole link is a credential: whoever has it can unsubscribe that address,
5
5
  // so it never reaches a log, an error or a result.
6
6
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
7
- import { getMailingRoutes } from "./options.js";
7
+ import { getMailingOptions, getMailingRoutes } from "./options.js";
8
8
  /** Signs every new link and verifies. */
9
9
  export const UNSUBSCRIBE_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET";
10
10
  /** Optional: verifies links signed before a rotation, never signs. */
@@ -69,6 +69,35 @@ export function buildUnsubscribeLinks(config, address, secret) {
69
69
  oneClick: `${config.appOrigin}${routes.oneClick}?${query.toString()}`,
70
70
  };
71
71
  }
72
+ /** A legacy link's value longer than this makes the link invalid before the app's `verify` sees it. */
73
+ export const MAX_LEGACY_VALUE_LENGTH = 512;
74
+ /**
75
+ * The link in a query or a form. A link with the recipient parameter (`r`) is a signed link, whatever else it
76
+ * carries, so a crafted URL cannot route a signed link to the app's legacy check. Without it, and with
77
+ * `mailing({ legacyUnsubscribe })`, a link that has every legacy parameter (non-empty, at most 512 characters) is
78
+ * a legacy link. Anything else is `null`. Checks no signature: `unsubscribe` does.
79
+ */
80
+ export function readUnsubscribeLink(params, config) {
81
+ if (params.get(RECIPIENT_PARAM) !== null && params.get(RECIPIENT_PARAM) !== undefined) {
82
+ const token = readUnsubscribeToken(params);
83
+ return token === null ? null : { scheme: "signed", token };
84
+ }
85
+ const legacy = getMailingOptions(config).legacyUnsubscribe;
86
+ if (legacy === undefined)
87
+ return null;
88
+ const values = {};
89
+ for (const name of legacy.params) {
90
+ const value = params.get(name);
91
+ if (typeof value !== "string" || value === "" || value.length > MAX_LEGACY_VALUE_LENGTH)
92
+ return null;
93
+ values[name] = value;
94
+ }
95
+ return { scheme: "legacy", values };
96
+ }
97
+ /** The query (or form) parameters that carry `link`, to send it back to the page. */
98
+ export function getUnsubscribeLinkParams(link) {
99
+ return link.scheme === "signed" ? { [RECIPIENT_PARAM]: link.token.recipientKey, [SIGNATURE_PARAM]: link.token.signature } : { ...link.values };
100
+ }
72
101
  /** The token in a link's query (or a form), when both parameters are present once. */
73
102
  export function readUnsubscribeToken(params) {
74
103
  const recipientKey = params.get(RECIPIENT_PARAM);
@@ -1 +1 @@
1
- {"version":3,"file":"unsubscribe-link.js","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"unsubscribe-link.js","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ -- Keeps the provider's HTTP status with a delivery (the last failed answer, also on a released claim), and lets a
2
+ -- released claim give its attempt back: a halt (refused key, spent quota) says nothing about the mail, so a
3
+ -- `pending` row may hold 0 attempts until it is claimed again.
4
+ -- Rollback: ALTER TABLE mailing.deliveries DROP COLUMN provider_status;
5
+ -- UPDATE mailing.deliveries SET attempts = 1 WHERE attempts = 0;
6
+ -- ALTER TABLE mailing.deliveries DROP CONSTRAINT deliveries_attempts_check;
7
+ -- ALTER TABLE mailing.deliveries ADD CONSTRAINT deliveries_attempts_check CHECK (attempts >= 1);
8
+ -- DELETE FROM softure.migrations WHERE module = 'mailing' AND version = 3;
9
+
10
+ ALTER TABLE deliveries ADD COLUMN provider_status integer CHECK (provider_status BETWEEN 100 AND 599);
11
+
12
+ ALTER TABLE deliveries DROP CONSTRAINT deliveries_attempts_check;
13
+ ALTER TABLE deliveries ADD CONSTRAINT deliveries_attempts_check CHECK (attempts >= 1 OR (attempts = 0 AND status = 'pending'));
package/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "mailing",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "dependsOn": {},
5
5
  "dbSchema": "mailing",
6
6
  "tables": ["suppressions", "campaigns", "deliveries"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/mailing",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "SOFTURE AI mailing module: sendMail over a provider adapter (Resend first) with plain-text and HTML bodies, sender and reply-to from config, idempotency keys, a timeout and a typed result; signed one-click unsubscribe (RFC 8058) with a suppression list for list mail; an exactly-once delivery ledger, campaigns sent from a content file by the softure-mail command, and a sender DNS check; a fake provider for tests.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
+ "CHANGELOG.md",
18
19
  "src",
19
20
  "!src/**/*.test.ts",
20
21
  "!src/**/*.test.tsx",
@@ -61,12 +62,15 @@
61
62
  "dependencies": {
62
63
  "@softure-ai/core": "^0.1.0",
63
64
  "@softure-ai/db": "^0.1.0",
64
- "@softure-ai/ui": "^0.1.0",
65
65
  "zod": "^4.6.5"
66
66
  },
67
67
  "peerDependencies": {
68
+ "@softure-ai/ui": "^0.1.0",
68
69
  "drizzle-orm": "^0.45.2",
69
70
  "next": "^16.0.0",
70
71
  "react": "^19.0.0"
72
+ },
73
+ "devDependencies": {
74
+ "@softure-ai/ui": "^0.1.7"
71
75
  }
72
76
  }
package/src/cli/run.ts CHANGED
@@ -9,11 +9,11 @@
9
9
  import { readFile } from "node:fs/promises";
10
10
  import { dirname, resolve } from "node:path";
11
11
  import { parseArgs } from "node:util";
12
- import { systemClock, type SoftureConfig } from "@softure-ai/core";
13
- import { createDatabase, type DatabaseHandle } from "@softure-ai/db";
12
+ import { systemClock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
13
+ import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
14
14
  import { parseCampaignFile, parseRecipientList, type CampaignContent } from "../server/campaign-file.js";
15
15
  import { planCampaign, sendCampaign, type CampaignSummary } from "../server/campaigns.js";
16
- import type { DeliveryContext } from "../server/deliveries.js";
16
+ import type { DeliveryContext, HaltingErrorCode } from "../server/deliveries.js";
17
17
  import { checkSenderDns, getSenderDomain, type DnsCheck, type ResolveTxt } from "../server/dns.js";
18
18
  import { getMailingOptions } from "../server/options.js";
19
19
  import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "../server/unsubscribe-link.js";
@@ -31,7 +31,7 @@ export interface RunMailCliOptions {
31
31
  /** Relative paths resolve against it. Default: `process.cwd()`. */
32
32
  readonly cwd?: string;
33
33
  readonly output?: CliOutput;
34
- /** Opens the campaign's database connection. Default: `createDatabase(url, { max: 1 })`. */
34
+ /** Opens the campaign's database connection. Default: the config's `database.handle`, else `createDatabase(url, { max: 1 })`. */
35
35
  readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
36
36
  readonly resolveTxt?: ResolveTxt;
37
37
  readonly sleep?: (ms: number) => Promise<void>;
@@ -46,14 +46,17 @@ export const EXIT_USAGE = 2;
46
46
  export const DEFAULT_PAUSE_MS = 500;
47
47
 
48
48
  export const MAIL_USAGE = `Usage:
49
- softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>]
49
+ softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
50
50
  softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
51
51
 
52
52
  campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
53
53
  then the text body) to every address in the recipients file (one per line), at most
54
- once each. Re-running sends only to recipients without an outcome yet.
55
- --dry-run count what would be sent and change nothing
56
- --pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
54
+ once each. Re-running sends only to recipients without an outcome yet. A refused
55
+ API key or a spent quota stops the run; the rest go out on the next one.
56
+ --dry-run count what would be sent and change nothing
57
+ --pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
58
+ --resend-uncertain also send to recipients whose send was interrupted long ago
59
+ (it may have gone out: they may get the mail twice)
57
60
 
58
61
  dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
59
62
  --domain <domain> check this domain instead
@@ -72,7 +75,14 @@ const consoleOutput: CliOutput = {
72
75
 
73
76
  type Command =
74
77
  | { readonly kind: "help" }
75
- | { readonly kind: "campaign"; readonly contentFile: string; readonly recipientsFile: string; readonly dryRun: boolean; readonly pauseMs: number }
78
+ | {
79
+ readonly kind: "campaign";
80
+ readonly contentFile: string;
81
+ readonly recipientsFile: string;
82
+ readonly dryRun: boolean;
83
+ readonly pauseMs: number;
84
+ readonly resendUncertain: boolean;
85
+ }
76
86
  | { readonly kind: "dns"; readonly domain: string | undefined; readonly dkimSelectors: readonly string[] | undefined; readonly spfHosts: readonly string[] | undefined };
77
87
 
78
88
  /** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
@@ -111,7 +121,7 @@ function parseCommand(argv: readonly string[]): Command | string {
111
121
  if (values.help === true) return { kind: "help" };
112
122
 
113
123
  if (name === "dns") {
114
- const misplaced = (["recipients", "dry-run", "pause-ms"] as const).filter((option) => values[option] !== undefined);
124
+ const misplaced = (["recipients", "dry-run", "pause-ms", "resend-uncertain"] as const).filter((option) => values[option] !== undefined);
115
125
  if (misplaced.length > 0) return `dns does not take --${misplaced.join(", --")}`;
116
126
  if (positionals.length > 0) return `dns takes no file, got "${positionals.join(" ")}"`;
117
127
  return { kind: "dns", domain: values.domain, dkimSelectors: values["dkim-selector"], spfHosts: values["spf-host"] };
@@ -125,7 +135,7 @@ function parseCommand(argv: readonly string[]): Command | string {
125
135
  if (values.recipients === undefined) return "campaign needs --recipients <file>";
126
136
  const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
127
137
  if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000) return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
128
- return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs };
138
+ return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
129
139
  }
130
140
 
131
141
  function parseCommandArgs(args: readonly string[]) {
@@ -135,6 +145,7 @@ function parseCommandArgs(args: readonly string[]) {
135
145
  recipients: { type: "string" },
136
146
  "dry-run": { type: "boolean" },
137
147
  "pause-ms": { type: "string" },
148
+ "resend-uncertain": { type: "boolean" },
138
149
  domain: { type: "string" },
139
150
  "dkim-selector": { type: "string", multiple: true },
140
151
  "spf-host": { type: "string", multiple: true },
@@ -196,22 +207,24 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
196
207
  return EXIT_FAILED;
197
208
  }
198
209
 
199
- let handle: DatabaseHandle;
210
+ let opened: CommandDatabase;
200
211
  try {
201
- handle = await (options.openDatabase ?? openDatabase)(config.database.url);
212
+ opened = await openDatabase(config.database, options.openDatabase);
202
213
  } catch (error) {
203
214
  output.error(`softure-mail campaign: ${describeError(error)}`);
204
215
  return EXIT_FAILED;
205
216
  }
206
- const ctx: DeliveryContext = { db: handle.db, clock: systemClock, config };
217
+ const ctx: DeliveryContext = { db: opened.handle.db, clock: systemClock, config };
207
218
  try {
208
- return command.dryRun ? await reportPlan(ctx, loaded, output) : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep }, output);
219
+ return command.dryRun
220
+ ? await reportPlan(ctx, loaded, command.resendUncertain, output)
221
+ : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain }, output);
209
222
  } catch (error) {
210
223
  // Driver errors: the message only, never a stack, an address or the database URL.
211
224
  output.error(`softure-mail campaign: ${describeError(error)} (did softure migrate run?)`);
212
225
  return EXIT_FAILED;
213
226
  } finally {
214
- await handle.close();
227
+ await opened.close();
215
228
  }
216
229
  }
217
230
 
@@ -238,10 +251,19 @@ async function loadCampaign(contentPath: string, recipientsPath: string): Promis
238
251
  return { campaign: { ...content, html }, recipients: parseRecipientList(list) };
239
252
  }
240
253
 
241
- async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, output: CliOutput): Promise<number> {
242
- const plan = await planCampaign(ctx, loaded);
254
+ async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, retakeUncertain: boolean, output: CliOutput): Promise<number> {
255
+ const plan = await planCampaign(ctx, loaded, { retakeUncertain });
243
256
  output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}), dry run: nothing sent or written`);
244
- output.log(`recipients ${String(plan.recipients)}, already done ${String(plan.done)}, unsubscribed ${String(plan.suppressed)}, to send ${String(plan.toSend)}`);
257
+ output.log(
258
+ [
259
+ `recipients ${String(plan.recipients)}`,
260
+ `already done ${String(plan.done)}`,
261
+ `unsubscribed ${String(plan.suppressed)}`,
262
+ `filtered out ${String(plan.filtered)}`,
263
+ `uncertain ${String(plan.uncertain)}`,
264
+ `to send ${String(plan.toSend)}`,
265
+ ].join(", "),
266
+ );
245
267
  if (plan.contentChanged) {
246
268
  output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
247
269
  return EXIT_FAILED;
@@ -252,13 +274,14 @@ async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, output:
252
274
  async function reportSend(
253
275
  ctx: DeliveryContext,
254
276
  loaded: LoadedCampaign,
255
- pace: { readonly pauseMs: number; readonly sleep: ((ms: number) => Promise<void>) | undefined },
277
+ pace: { readonly pauseMs: number; readonly sleep: ((ms: number) => Promise<void>) | undefined; readonly retakeUncertain: boolean },
256
278
  output: CliOutput,
257
279
  ): Promise<number> {
258
280
  let handled = 0;
259
281
  const total = loaded.recipients.length;
260
282
  const result = await sendCampaign(ctx, loaded, {
261
283
  pauseMs: pace.pauseMs,
284
+ retakeUncertain: pace.retakeUncertain,
262
285
  ...(pace.sleep === undefined ? {} : { sleep: pace.sleep }),
263
286
  onDelivery: () => {
264
287
  handled += 1;
@@ -269,15 +292,33 @@ async function reportSend(
269
292
  output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
270
293
  return EXIT_FAILED;
271
294
  }
272
- output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}): ${formatSummary(result.value)}`);
273
- const open = result.value.inFlight + result.value.retryLater;
274
- if (open > 0) {
295
+ const summary = result.value;
296
+ output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}): ${formatSummary(summary)}`);
297
+ let code = EXIT_OK;
298
+ if (summary.halted !== null) {
299
+ const status = summary.halted.httpStatus === undefined ? "" : ` (HTTP ${String(summary.halted.httpStatus)})`;
300
+ output.error(`stopped: ${HALT_EXPLANATIONS[summary.halted.reason]}${status}; the remaining recipients were not touched, run the same command again once it is fixed`);
301
+ code = EXIT_FAILED;
302
+ }
303
+ const open = summary.inFlight + summary.retryLater;
304
+ if (open > 0 && summary.halted === null) {
275
305
  output.error(`${String(open)} recipient(s) have no outcome yet; run the same command again later`);
276
- return EXIT_FAILED;
306
+ code = EXIT_FAILED;
277
307
  }
278
- return EXIT_OK;
308
+ if (summary.uncertain > 0) {
309
+ output.error(
310
+ `${String(summary.uncertain)} recipient(s) are uncertain: a send was interrupted long ago and may have gone out; check the provider's log, then run again with --resend-uncertain to send them anyway`,
311
+ );
312
+ code = EXIT_FAILED;
313
+ }
314
+ return code;
279
315
  }
280
316
 
317
+ const HALT_EXPLANATIONS: Readonly<Record<HaltingErrorCode, string>> = {
318
+ "mailing.provider_refused": "the provider refused the account (check the API key and the account)",
319
+ "mailing.quota_exceeded": "the account's sending quota is spent",
320
+ };
321
+
281
322
  function formatSummary(summary: CampaignSummary): string {
282
323
  const rejected = Object.entries(summary.rejected).filter(([, count]) => count > 0);
283
324
  const rejectedTotal = rejected.reduce((sum, [, count]) => sum + count, 0);
@@ -289,11 +330,15 @@ function formatSummary(summary: CampaignSummary): string {
289
330
  `already done ${String(summary.done)}`,
290
331
  `in flight ${String(summary.inFlight)}`,
291
332
  `retry later ${String(summary.retryLater)}`,
333
+ `filtered out ${String(summary.filtered)}`,
334
+ `uncertain ${String(summary.uncertain)}`,
292
335
  ].join(", ");
293
336
  }
294
337
 
295
- function openDatabase(url: string): Promise<DatabaseHandle> {
296
- return createDatabase(url, { max: 1 });
338
+ async function openDatabase(database: SoftureDatabaseConfig, open: ((url: string) => Promise<DatabaseHandle>) | undefined): Promise<CommandDatabase> {
339
+ if (open === undefined) return openCommandDatabase(database, { max: 1 });
340
+ const handle = await open(database.url);
341
+ return { handle, close: handle.close };
297
342
  }
298
343
 
299
344
  async function readText(path: string): Promise<string | null> {