@softure-ai/mailing 0.1.6 → 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 (87) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +83 -18
  3. package/dist/cli/run.d.ts.map +1 -1
  4. package/dist/cli/run.js +46 -15
  5. package/dist/cli/run.js.map +1 -1
  6. package/dist/contract.d.ts +48 -8
  7. package/dist/contract.d.ts.map +1 -1
  8. package/dist/contract.js +2 -0
  9. package/dist/contract.js.map +1 -1
  10. package/dist/index.d.ts +12 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -3
  13. package/dist/index.js.map +1 -1
  14. package/dist/messages/en.d.ts +2 -0
  15. package/dist/messages/en.d.ts.map +1 -1
  16. package/dist/messages/en.js +2 -0
  17. package/dist/messages/en.js.map +1 -1
  18. package/dist/messages/index.d.ts +4 -0
  19. package/dist/messages/index.d.ts.map +1 -1
  20. package/dist/messages/pl.d.ts.map +1 -1
  21. package/dist/messages/pl.js +2 -0
  22. package/dist/messages/pl.js.map +1 -1
  23. package/dist/next/actions.d.ts.map +1 -1
  24. package/dist/next/actions.js +6 -6
  25. package/dist/next/actions.js.map +1 -1
  26. package/dist/next/pages.d.ts.map +1 -1
  27. package/dist/next/pages.js +8 -6
  28. package/dist/next/pages.js.map +1 -1
  29. package/dist/next/route.d.ts +2 -1
  30. package/dist/next/route.d.ts.map +1 -1
  31. package/dist/next/route.js +6 -4
  32. package/dist/next/route.js.map +1 -1
  33. package/dist/options.d.ts +15 -1
  34. package/dist/options.d.ts.map +1 -1
  35. package/dist/options.js +32 -1
  36. package/dist/options.js.map +1 -1
  37. package/dist/providers/resend.d.ts.map +1 -1
  38. package/dist/providers/resend.js +12 -6
  39. package/dist/providers/resend.js.map +1 -1
  40. package/dist/schema.d.ts +17 -0
  41. package/dist/schema.d.ts.map +1 -1
  42. package/dist/schema.js +2 -0
  43. package/dist/schema.js.map +1 -1
  44. package/dist/server/campaigns.d.ts +30 -4
  45. package/dist/server/campaigns.d.ts.map +1 -1
  46. package/dist/server/campaigns.js +67 -12
  47. package/dist/server/campaigns.js.map +1 -1
  48. package/dist/server/deliveries.d.ts +30 -2
  49. package/dist/server/deliveries.d.ts.map +1 -1
  50. package/dist/server/deliveries.js +58 -23
  51. package/dist/server/deliveries.js.map +1 -1
  52. package/dist/server/index.d.ts +3 -3
  53. package/dist/server/index.d.ts.map +1 -1
  54. package/dist/server/index.js +2 -2
  55. package/dist/server/index.js.map +1 -1
  56. package/dist/server/send-mail.d.ts +3 -1
  57. package/dist/server/send-mail.d.ts.map +1 -1
  58. package/dist/server/send-mail.js +18 -6
  59. package/dist/server/send-mail.js.map +1 -1
  60. package/dist/server/suppressions.d.ts +8 -6
  61. package/dist/server/suppressions.d.ts.map +1 -1
  62. package/dist/server/suppressions.js +23 -7
  63. package/dist/server/suppressions.js.map +1 -1
  64. package/dist/server/unsubscribe-link.d.ts +23 -0
  65. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  66. package/dist/server/unsubscribe-link.js +30 -1
  67. package/dist/server/unsubscribe-link.js.map +1 -1
  68. package/migrations/0003_add_delivery_provider_status.sql +13 -0
  69. package/module.json +1 -1
  70. package/package.json +1 -1
  71. package/src/cli/run.ts +61 -18
  72. package/src/contract.ts +49 -6
  73. package/src/index.ts +8 -3
  74. package/src/messages/en.ts +2 -0
  75. package/src/messages/pl.ts +2 -0
  76. package/src/next/actions.ts +5 -6
  77. package/src/next/pages.tsx +10 -7
  78. package/src/next/route.ts +6 -4
  79. package/src/options.ts +36 -2
  80. package/src/providers/resend.ts +11 -7
  81. package/src/schema.ts +2 -0
  82. package/src/server/campaigns.ts +95 -17
  83. package/src/server/deliveries.ts +89 -29
  84. package/src/server/index.ts +8 -0
  85. package/src/server/send-mail.ts +30 -6
  86. package/src/server/suppressions.ts +23 -8
  87. package/src/server/unsubscribe-link.ts +41 -1
@@ -5,7 +5,17 @@
5
5
  // link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
6
6
  import { err, ok, type SoftureConfig } from "@softure-ai/core";
7
7
  import type { Queryable } from "@softure-ai/db";
8
- import { TRANSACTIONAL_KIND, type MailingErrorCode, type MailProvider, type OutgoingMail, type ProviderMessage, type ProviderOutcome, type SendMailOptions, type SendMailResult } from "../contract.js";
8
+ import {
9
+ TRANSACTIONAL_KIND,
10
+ type MailingErrorCode,
11
+ type MailProvider,
12
+ type OutgoingMail,
13
+ type ProviderFailureStatus,
14
+ type ProviderMessage,
15
+ type ProviderOutcome,
16
+ type SendMailOptions,
17
+ type SendMailResult,
18
+ } from "../contract.js";
9
19
  import type { MailingMessages } from "../messages/index.js";
10
20
  import { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders } from "./list-mail.js";
11
21
  import { getMailingModule, getMailingOptions } from "./options.js";
@@ -27,7 +37,9 @@ type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
27
37
  /**
28
38
  * Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
29
39
  * `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
30
- * same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry).
40
+ * same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry),
41
+ * `provider_refused` / `quota_exceeded` (the account cannot send: stop, fix or wait, then retry). A
42
+ * failure the provider answered with an HTTP status carries it as `httpStatus`.
31
43
  * Throws only when the module is not enabled, or for list mail without `context.db`.
32
44
  */
33
45
  export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
@@ -54,11 +66,18 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
54
66
  if (outcome.status === "sent") {
55
67
  return ok({ id: outcome.id, provider: provider.name });
56
68
  }
57
- const code: MailingErrorCode = `mailing.${outcome.status}`;
69
+ const code = FAILURE_CODES[outcome.status];
58
70
  logFailure(provider, code, { status: outcome.httpStatus });
59
- return err(code);
71
+ return outcome.httpStatus === undefined ? err(code) : { ...err(code), httpStatus: outcome.httpStatus };
60
72
  }
61
73
 
74
+ const FAILURE_CODES: Readonly<Record<ProviderFailureStatus, MailingErrorCode>> = {
75
+ rejected: "mailing.rejected",
76
+ unavailable: "mailing.unavailable",
77
+ refused: "mailing.provider_refused",
78
+ quota_exceeded: "mailing.quota_exceeded",
79
+ };
80
+
62
81
  type ListMailResult =
63
82
  | { readonly ok: true; readonly value: MailContent }
64
83
  | { readonly ok: false; readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
@@ -130,12 +149,17 @@ function readOutcome(outcome: unknown): ProviderOutcome {
130
149
  if (status === "sent") {
131
150
  return typeof id === "string" && id !== "" ? { status, id } : { status: "unavailable" };
132
151
  }
133
- if (status === "rejected" || status === "unavailable") {
134
- return typeof httpStatus === "number" ? { status, httpStatus } : { status };
152
+ if (typeof status === "string" && Object.hasOwn(FAILURE_CODES, status)) {
153
+ const failure = status as ProviderFailureStatus;
154
+ return isHttpStatus(httpStatus) ? { status: failure, httpStatus } : { status: failure };
135
155
  }
136
156
  return { status: "unavailable" };
137
157
  }
138
158
 
159
+ function isHttpStatus(value: unknown): value is number {
160
+ return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599;
161
+ }
162
+
139
163
  /**
140
164
  * The module's only log line. Deliberately takes nothing but the provider, the code, a status,
141
165
  * field names and a fixed cause: anything added "for diagnosis" is where an address leaks first.
@@ -9,7 +9,7 @@ import { and, eq, inArray } from "drizzle-orm";
9
9
  import type { SuppressionSource, UnsubscribeErrorCode } from "../contract.js";
10
10
  import { suppressions } from "../schema.js";
11
11
  import { getMailingOptions } from "./options.js";
12
- import { getRecipientKey, readUnsubscribeSecrets, verifyUnsubscribeToken, type Env, type UnsubscribeToken } from "./unsubscribe-link.js";
12
+ import { getRecipientKey, readUnsubscribeSecrets, verifyUnsubscribeToken, type Env, type UnsubscribeLink, type UnsubscribeToken } from "./unsubscribe-link.js";
13
13
 
14
14
  export type SuppressionContext = ModuleContext<Queryable>;
15
15
 
@@ -54,20 +54,22 @@ export async function liftSuppression(ctx: Pick<SuppressionContext, "db">, addre
54
54
  }
55
55
 
56
56
  /**
57
- * Unsubscribes the recipient of a signed link and runs `onUnsubscribed` in the same transaction,
58
- * also when the recipient had already unsubscribed (a retried one-click heals a missed hook). The
59
- * signature is checked before the database is touched; a link that does not verify is
60
- * `mailing.invalid_link`. Throws on a database failure or when the hook throws (nothing is stored).
57
+ * Unsubscribes the recipient of a link and runs `onUnsubscribed` in the same transaction, also when
58
+ * the recipient had already unsubscribed (a retried one-click heals a missed hook). A signed link
59
+ * (or a bare token) is checked against the secrets before the database is touched; a legacy link
60
+ * goes to the app's `legacyUnsubscribe.verify`, which names the address. A link that does not
61
+ * verify is `mailing.invalid_link`. Throws on a database failure, or when `verify` or the hook
62
+ * throws (nothing is stored).
61
63
  */
62
64
  export async function unsubscribe(
63
65
  ctx: SuppressionContext,
64
- token: UnsubscribeToken | null,
66
+ link: UnsubscribeLink | UnsubscribeToken | null,
65
67
  source: Exclude<SuppressionSource, "operator">,
66
68
  env: Env = process.env,
67
69
  ): Promise<Result<undefined, UnsubscribeErrorCode>> {
68
- if (token === null || !verifyUnsubscribeToken(token, readUnsubscribeSecrets(env))) return err("mailing.invalid_link");
70
+ const recipientKey = await verifyLink(ctx, link, env);
71
+ if (recipientKey === null) return err("mailing.invalid_link");
69
72
  const { onUnsubscribed } = getMailingOptions(ctx.config);
70
- const { recipientKey } = token;
71
73
  await ctx.db.transaction(async (tx) => {
72
74
  const txCtx: SuppressionContext = { ...ctx, db: tx };
73
75
  await recordSuppression(txCtx, recipientKey, source);
@@ -75,3 +77,16 @@ export async function unsubscribe(
75
77
  });
76
78
  return ok();
77
79
  }
80
+
81
+ /** The recipient key a link proves, or `null` when it proves none. */
82
+ async function verifyLink(ctx: SuppressionContext, link: UnsubscribeLink | UnsubscribeToken | null, env: Env): Promise<string | null> {
83
+ if (link === null) return null;
84
+ if (!("scheme" in link) || link.scheme === "signed") {
85
+ const token = "scheme" in link ? link.token : link;
86
+ return verifyUnsubscribeToken(token, readUnsubscribeSecrets(env)) ? token.recipientKey : null;
87
+ }
88
+ const legacy = getMailingOptions(ctx.config).legacyUnsubscribe;
89
+ if (legacy === undefined) return null;
90
+ const address: unknown = await legacy.verify(link.values, ctx);
91
+ return typeof address === "string" && address.trim() !== "" ? getRecipientKey(address) : null;
92
+ }
@@ -5,7 +5,7 @@
5
5
  // so it never reaches a log, an error or a result.
6
6
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
7
7
  import type { SoftureConfig } from "@softure-ai/core";
8
- import { getMailingRoutes } from "./options.js";
8
+ import { getMailingOptions, getMailingRoutes } from "./options.js";
9
9
 
10
10
  /** Signs every new link and verifies. */
11
11
  export const UNSUBSCRIBE_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET";
@@ -101,6 +101,46 @@ export function buildUnsubscribeLinks(config: SoftureConfig, address: string, se
101
101
  };
102
102
  }
103
103
 
104
+ /** A legacy link's value longer than this makes the link invalid before the app's `verify` sees it. */
105
+ export const MAX_LEGACY_VALUE_LENGTH = 512;
106
+
107
+ /** The link a person followed: the module's signed one, or one the app sent before it adopted the module. */
108
+ export type UnsubscribeLink =
109
+ | { readonly scheme: "signed"; readonly token: UnsubscribeToken }
110
+ | { readonly scheme: "legacy"; readonly values: Readonly<Record<string, string>> };
111
+
112
+ /** Reads one query or form value; `URLSearchParams` and `FormData` both fit. */
113
+ export interface LinkParams {
114
+ get(name: string): unknown;
115
+ }
116
+
117
+ /**
118
+ * The link in a query or a form. A link with the recipient parameter (`r`) is a signed link, whatever else it
119
+ * carries, so a crafted URL cannot route a signed link to the app's legacy check. Without it, and with
120
+ * `mailing({ legacyUnsubscribe })`, a link that has every legacy parameter (non-empty, at most 512 characters) is
121
+ * a legacy link. Anything else is `null`. Checks no signature: `unsubscribe` does.
122
+ */
123
+ export function readUnsubscribeLink(params: LinkParams, config: SoftureConfig): UnsubscribeLink | null {
124
+ if (params.get(RECIPIENT_PARAM) !== null && params.get(RECIPIENT_PARAM) !== undefined) {
125
+ const token = readUnsubscribeToken(params);
126
+ return token === null ? null : { scheme: "signed", token };
127
+ }
128
+ const legacy = getMailingOptions(config).legacyUnsubscribe;
129
+ if (legacy === undefined) return null;
130
+ const values: Record<string, string> = {};
131
+ for (const name of legacy.params) {
132
+ const value = params.get(name);
133
+ if (typeof value !== "string" || value === "" || value.length > MAX_LEGACY_VALUE_LENGTH) return null;
134
+ values[name] = value;
135
+ }
136
+ return { scheme: "legacy", values };
137
+ }
138
+
139
+ /** The query (or form) parameters that carry `link`, to send it back to the page. */
140
+ export function getUnsubscribeLinkParams(link: UnsubscribeLink): Record<string, string> {
141
+ return link.scheme === "signed" ? { [RECIPIENT_PARAM]: link.token.recipientKey, [SIGNATURE_PARAM]: link.token.signature } : { ...link.values };
142
+ }
143
+
104
144
  /** The token in a link's query (or a form), when both parameters are present once. */
105
145
  export function readUnsubscribeToken(params: { get(name: string): unknown }): UnsubscribeToken | null {
106
146
  const recipientKey = params.get(RECIPIENT_PARAM);