@softure-ai/mailing 0.2.1 → 0.2.3

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 (41) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -2
  3. package/dist/cli/command.js +8 -1
  4. package/dist/cli/command.js.map +1 -1
  5. package/dist/cli/run.d.ts.map +1 -1
  6. package/dist/cli/run.js +8 -7
  7. package/dist/cli/run.js.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/next/actions.d.ts.map +1 -1
  10. package/dist/next/actions.js +8 -26
  11. package/dist/next/actions.js.map +1 -1
  12. package/dist/next/page-submit.d.ts +12 -0
  13. package/dist/next/page-submit.d.ts.map +1 -0
  14. package/dist/next/page-submit.js +30 -0
  15. package/dist/next/page-submit.js.map +1 -0
  16. package/dist/next/pages.d.ts.map +1 -1
  17. package/dist/next/pages.js +8 -5
  18. package/dist/next/pages.js.map +1 -1
  19. package/dist/next/route.d.ts +4 -1
  20. package/dist/next/route.d.ts.map +1 -1
  21. package/dist/next/route.js +58 -2
  22. package/dist/next/route.js.map +1 -1
  23. package/dist/next/strip-link-query.d.ts +11 -0
  24. package/dist/next/strip-link-query.d.ts.map +1 -0
  25. package/dist/next/strip-link-query.js +25 -0
  26. package/dist/next/strip-link-query.js.map +1 -0
  27. package/dist/server/send-mail.d.ts +6 -0
  28. package/dist/server/send-mail.d.ts.map +1 -1
  29. package/dist/server/send-mail.js +24 -1
  30. package/dist/server/send-mail.js.map +1 -1
  31. package/module.json +1 -1
  32. package/package.json +1 -1
  33. package/src/cli/command.ts +10 -2
  34. package/src/cli/run.ts +8 -8
  35. package/src/index.ts +1 -1
  36. package/src/next/actions.ts +8 -24
  37. package/src/next/page-submit.ts +35 -0
  38. package/src/next/pages.tsx +9 -5
  39. package/src/next/route.ts +59 -4
  40. package/src/next/strip-link-query.tsx +30 -0
  41. package/src/server/send-mail.ts +26 -1
@@ -8,25 +8,42 @@
8
8
  // purpose: a few provider addresses act for millions of people, so a bucket would refuse real
9
9
  // unsubscribes. The link is checked by shape and signature before the database is touched, and
10
10
  // the only write is an idempotent insert.
11
+ //
12
+ // The package page's form posts here too, to the bare path with the link in its body, so the
13
+ // signed token never sits in a POST's request line (a server action would post to the page's URL,
14
+ // query included). No Origin check, as for a mail client: the signature is the authorization.
11
15
  import { errorLogLabel } from "@softure-ai/core";
12
16
  import { getSoftureConfig } from "@softure-ai/core/next";
13
17
  import { getMailingOptions, getMailingRoutes } from "../server/options.js";
14
18
  import { unsubscribe } from "../server/suppressions.js";
15
19
  import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
16
20
  import { getMailingContext } from "./context.js";
21
+ import { submitPageUnsubscribe } from "./page-submit.js";
17
22
  const NO_STORE = { "cache-control": "no-store" };
18
23
  /**
19
- * Records the opt-out of the link in the URL. The body (`List-Unsubscribe=One-Click`) is not read:
24
+ * Records the opt-out of the link in the URL. Its body (`List-Unsubscribe=One-Click`) is not read:
20
25
  * anyone can send it, the signature is the proof. 200 once recorded (also again), 400 for a link
21
26
  * that does not verify (200 with `mailing({ oneClickInvalidLinkStatus: 200 })`, so the answer never
22
27
  * tells whether a token is live), 500 when the database fails: the client may retry, and 200 would
23
28
  * claim an opt-out that did not happen. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
24
29
  * way; mount this route at the old link's path too.
30
+ *
31
+ * A POST without a link in the URL is the unsubscribe page's form: its link comes from the form
32
+ * body, and the answer is a 303 to the page's outcome (`?status=done`, `invalid` or `failed`).
25
33
  */
26
34
  export async function postUnsubscribeRoute(request) {
35
+ const config = getSoftureConfig();
36
+ const { searchParams } = new URL(request.url);
37
+ if (readUnsubscribeLink(searchParams, config) === null) {
38
+ const formLink = await readFormLink(request, config);
39
+ if (formLink !== null) {
40
+ const location = await submitPageUnsubscribe({ config, link: formLink, getContext: () => getMailingContext(config) });
41
+ return new Response(null, { status: 303, headers: { ...NO_STORE, location } });
42
+ }
43
+ }
27
44
  try {
28
45
  const ctx = await getMailingContext();
29
- const link = readUnsubscribeLink(new URL(request.url).searchParams, ctx.config);
46
+ const link = readUnsubscribeLink(searchParams, ctx.config);
30
47
  const result = await unsubscribe(ctx, link, "one-click");
31
48
  return new Response(null, { status: result.ok ? 200 : getMailingOptions(ctx.config).oneClickInvalidLinkStatus, headers: NO_STORE });
32
49
  }
@@ -44,4 +61,43 @@ export function getUnsubscribeRoute(request) {
44
61
  const { search } = new URL(request.url);
45
62
  return new Response(null, { status: 303, headers: { ...NO_STORE, location: `${getMailingRoutes(getSoftureConfig()).unsubscribe}${search}` } });
46
63
  }
64
+ /**
65
+ * The page's form is URL-encoded and small (a signed link is about 100 bytes; a legacy one at most a few 512-character
66
+ * values). A larger body is no page submit, and is not read past this size.
67
+ */
68
+ const MAX_FORM_BYTES = 8192;
69
+ const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
70
+ /** The link in a URL-encoded form body, or null for any other body. */
71
+ async function readFormLink(request, config) {
72
+ if (request.headers.get("content-type")?.toLowerCase().startsWith(FORM_CONTENT_TYPE) !== true)
73
+ return null;
74
+ const body = await readBoundedText(request, MAX_FORM_BYTES);
75
+ return body === null ? null : readUnsubscribeLink(new URLSearchParams(body), config);
76
+ }
77
+ /** The body as text, or null when it is longer than `maxBytes` (the rest is not read) or cannot be read. */
78
+ async function readBoundedText(request, maxBytes) {
79
+ if (request.body === null)
80
+ return "";
81
+ const reader = request.body.getReader();
82
+ const chunks = [];
83
+ let size = 0;
84
+ try {
85
+ for (;;) {
86
+ const { done, value } = await reader.read();
87
+ if (done)
88
+ break;
89
+ size += value.byteLength;
90
+ if (size > maxBytes) {
91
+ await reader.cancel();
92
+ return null;
93
+ }
94
+ chunks.push(value);
95
+ }
96
+ }
97
+ catch {
98
+ // A client that drops the connection mid-body: no form to read.
99
+ return null;
100
+ }
101
+ return Buffer.concat(chunks).toString("utf8");
102
+ }
47
103
  //# sourceMappingURL=route.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"route.js","sourceRoot":"","sources":["../../src/next/route.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,wCAAwC;AACxC,yGAAyG;AACzG,EAAE;AACF,6FAA6F;AAC7F,gGAAgG;AAChG,0FAA0F;AAC1F,8FAA8F;AAC9F,+FAA+F;AAC/F,0CAA0C;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AACpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAEjD,MAAM,QAAQ,GAAG,EAAE,eAAe,EAAE,UAAU,EAAE,CAAC;AAEjD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,OAAgB;IACzD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,iBAAiB,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,mBAAmB,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;QAChF,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,GAAG,EAAE,IAAI,EAAE,WAAW,CAAC,CAAC;QACzD,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,iBAAiB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,yBAAyB,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAC;IACtI,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,sDAAsD,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC5F,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAC;IAChE,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAAgB;IAClD,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,GAAG,QAAQ,EAAE,QAAQ,EAAE,GAAG,gBAAgB,CAAC,gBAAgB,EAAE,CAAC,CAAC,WAAW,GAAG,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC;AACjJ,CAAC"}
1
+ {"version":3,"file":"route.js","sourceRoot":"","sources":["../../src/next/route.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ export interface StripLinkQueryProps {
2
+ /** The query parameters that carry the link. */
3
+ readonly names: readonly string[];
4
+ }
5
+ export declare function StripLinkQuery({ names }: StripLinkQueryProps): null;
6
+ /**
7
+ * Replaces the current history entry with the URL minus `names`. The current `history.state` goes
8
+ * along: it carries Next's own marker, so its router lets the call through without a navigation.
9
+ */
10
+ export declare function stripQueryParams(names: readonly string[]): void;
11
+ //# sourceMappingURL=strip-link-query.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strip-link-query.d.ts","sourceRoot":"","sources":["../../src/next/strip-link-query.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,mBAAmB;IAClC,gDAAgD;IAChD,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED,wBAAgB,cAAc,CAAC,EAAE,KAAK,EAAE,EAAE,mBAAmB,QAK5D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAM/D"}
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ // Takes the unsubscribe link's parameters out of the address bar once the page has loaded, so the
3
+ // signed token does not stay in the tab's URL and the browser history. It renders nothing; without
4
+ // JavaScript the address bar keeps the link, and the form still works.
5
+ import { useEffect } from "react";
6
+ export function StripLinkQuery({ names }) {
7
+ useEffect(() => {
8
+ stripQueryParams(names);
9
+ }, [names]);
10
+ return null;
11
+ }
12
+ /**
13
+ * Replaces the current history entry with the URL minus `names`. The current `history.state` goes
14
+ * along: it carries Next's own marker, so its router lets the call through without a navigation.
15
+ */
16
+ export function stripQueryParams(names) {
17
+ const url = new URL(location.href);
18
+ const present = names.filter((name) => url.searchParams.has(name));
19
+ if (present.length === 0)
20
+ return;
21
+ for (const name of present)
22
+ url.searchParams.delete(name);
23
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
24
+ }
25
+ //# sourceMappingURL=strip-link-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strip-link-query.js","sourceRoot":"","sources":["../../src/next/strip-link-query.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,kGAAkG;AAClG,mGAAmG;AACnG,uEAAuE;AACvE,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAOlC,MAAM,UAAU,cAAc,CAAC,EAAE,KAAK,EAAuB;IAC3D,SAAS,CAAC,GAAG,EAAE;QACb,gBAAgB,CAAC,KAAK,CAAC,CAAC;IAC1B,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IACZ,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAwB;IACvD,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IACnC,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;IACnE,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IACjC,KAAK,MAAM,IAAI,IAAI,OAAO;QAAE,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC1D,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,EAAE,GAAG,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;AACrF,CAAC"}
@@ -20,6 +20,12 @@ export interface MailContext {
20
20
  * Throws only when the module is not enabled, or for list mail without `context.db`.
21
21
  */
22
22
  export declare function sendMail(context: MailContext, mail: OutgoingMail, options?: SendMailOptions): Promise<SendMailResult>;
23
+ /**
24
+ * `sendMail` without the suppression check, for `softure-mail test` without a database: its recipient is always the
25
+ * operator's own `testAddress`. List mail still needs the secret. Internal: not exported from `/server`, so no app
26
+ * code can send list mail to a real recipient past the suppression list.
27
+ */
28
+ export declare function sendMailUnchecked(config: SoftureConfig, mail: OutgoingMail, options?: SendMailOptions): Promise<SendMailResult>;
23
29
  /** Why `previewMail` cannot render a mail: invalid input (with the fields), or list mail without the secret. */
24
30
  export type MailPreviewFailure = (Err<"mailing.invalid_input"> & {
25
31
  readonly fields: readonly string[];
@@ -1 +1 @@
1
- {"version":3,"file":"send-mail.d.ts","sourceRoot":"","sources":["../../src/server/send-mail.ts"],"names":[],"mappings":"AAKA,OAAO,EAAO,KAAK,GAAG,EAAM,KAAK,EAAE,EAAkB,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAIL,KAAK,YAAY,EAEjB,KAAK,eAAe,EAEpB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,gBAAgB,CAAC;AAKxB,OAAO,EAAwG,KAAK,GAAG,EAAE,MAAM,uBAAuB,CAAC;AAGvJ;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CA6B/H;AAED,gHAAgH;AAChH,MAAM,MAAM,kBAAkB,GAC1B,CAAC,GAAG,CAAC,uBAAuB,CAAC,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC,GACvE,CAAC,GAAG,CAAC,qBAAqB,CAAC,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,uBAAuB,CAAA;CAAE,CAAC,CAAC;AAE/E,MAAM,MAAM,iBAAiB,GAAG,EAAE,CAAC,eAAe,CAAC,GAAG,kBAAkB,CAAC;AAEzE,MAAM,WAAW,kBAAmB,SAAQ,eAAe;IACzD,oEAAoE;IACpE,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,kBAAuB,GAAG,iBAAiB,CAO1H"}
1
+ {"version":3,"file":"send-mail.d.ts","sourceRoot":"","sources":["../../src/server/send-mail.ts"],"names":[],"mappings":"AAKA,OAAO,EAAO,KAAK,GAAG,EAAM,KAAK,EAAE,EAAkB,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAIL,KAAK,YAAY,EAEjB,KAAK,eAAe,EAEpB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,gBAAgB,CAAC;AAKxB,OAAO,EAAwG,KAAK,GAAG,EAAE,MAAM,uBAAuB,CAAC;AAGvJ;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CAmB/H;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CAazI;AAiBD,gHAAgH;AAChH,MAAM,MAAM,kBAAkB,GAC1B,CAAC,GAAG,CAAC,uBAAuB,CAAC,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC,GACvE,CAAC,GAAG,CAAC,qBAAqB,CAAC,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,uBAAuB,CAAA;CAAE,CAAC,CAAC;AAE/E,MAAM,MAAM,iBAAiB,GAAG,EAAE,CAAC,eAAe,CAAC,GAAG,kBAAkB,CAAC;AAEzE,MAAM,WAAW,kBAAmB,SAAQ,eAAe;IACzD,oEAAoE;IACpE,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,kBAAuB,GAAG,iBAAiB,CAO1H"}
@@ -35,7 +35,30 @@ export async function sendMail(context, mail, options = {}) {
35
35
  return err(refusal.error);
36
36
  }
37
37
  }
38
- const outcome = await callProvider(provider, composeMessage(context.config, valid, secret), timeoutMs);
38
+ return deliver(provider, composeMessage(context.config, valid, secret), timeoutMs);
39
+ }
40
+ /**
41
+ * `sendMail` without the suppression check, for `softure-mail test` without a database: its recipient is always the
42
+ * operator's own `testAddress`. List mail still needs the secret. Internal: not exported from `/server`, so no app
43
+ * code can send list mail to a real recipient past the suppression list.
44
+ */
45
+ export async function sendMailUnchecked(config, mail, options = {}) {
46
+ const { provider, timeoutMs } = getMailingOptions(config);
47
+ const validation = validateMail(mail, options);
48
+ if (!validation.ok) {
49
+ logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
50
+ return err("mailing.invalid_input");
51
+ }
52
+ const secret = readUnsubscribeSecrets().current;
53
+ if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) {
54
+ logFailure(provider, "mailing.unavailable", { cause: "no_unsubscribe_secret" });
55
+ return err("mailing.unavailable");
56
+ }
57
+ return deliver(provider, composeMessage(config, validation.value, secret), timeoutMs);
58
+ }
59
+ /** Hands `message` to the provider and maps its outcome to the result, logging a failure. */
60
+ async function deliver(provider, message, timeoutMs) {
61
+ const outcome = await callProvider(provider, message, timeoutMs);
39
62
  if (outcome.status === "sent") {
40
63
  return ok({ id: outcome.id, provider: provider.name });
41
64
  }
@@ -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":"AAAA,iGAAiG;AACjG,oGAAoG;AACpG,mGAAmG;AACnG,mHAAmH;AACnH,8FAA8F;AAC9F,OAAO,EAAE,GAAG,EAAY,EAAE,EAAW,cAAc,EAAsB,MAAM,kBAAkB,CAAC;AAElG,OAAO,EACL,kBAAkB,GASnB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AACzF,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,qBAAqB,EAAE,6BAA6B,EAAE,sBAAsB,EAAE,sBAAsB,EAAY,MAAM,uBAAuB,CAAC;AACvJ,OAAO,EAAE,YAAY,EAAkB,MAAM,oBAAoB,CAAC;AAWlE;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,OAAoB,EAAE,IAAkB,EAAE,UAA2B,EAAE;IACpG,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,iBAAiB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAElE,MAAM,UAAU,GAAG,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAC/C,IAAI,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC;QACnB,UAAU,CAAC,QAAQ,EAAE,uBAAuB,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QAC7E,OAAO,GAAG,CAAC,uBAAuB,CAAC,CAAC;IACtC,CAAC;IACD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC;IAC/B,MAAM,MAAM,GAAG,sBAAsB,EAAE,CAAC,OAAO,CAAC;IAChD,IAAI,KAAK,CAAC,IAAI,KAAK,kBAAkB,EAAE,CAAC;QACtC,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;QAC5D,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,UAAU,CAAC,QAAQ,EAAE,OAAO,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;YAC9D,OAAO,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAC5B,CAAC;IACH,CAAC;IAED,OAAO,OAAO,CAAC,QAAQ,EAAE,cAAc,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC;AACrF,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,MAAqB,EAAE,IAAkB,EAAE,UAA2B,EAAE;IAC9G,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;IAC1D,MAAM,UAAU,GAAG,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAC/C,IAAI,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC;QACnB,UAAU,CAAC,QAAQ,EAAE,uBAAuB,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;QAC7E,OAAO,GAAG,CAAC,uBAAuB,CAAC,CAAC;IACtC,CAAC;IACD,MAAM,MAAM,GAAG,sBAAsB,EAAE,CAAC,OAAO,CAAC;IAChD,IAAI,UAAU,CAAC,KAAK,CAAC,IAAI,KAAK,kBAAkB,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QACpE,UAAU,CAAC,QAAQ,EAAE,qBAAqB,EAAE,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC,CAAC;QAChF,OAAO,GAAG,CAAC,qBAAqB,CAAC,CAAC;IACpC,CAAC;IACD,OAAO,OAAO,CAAC,QAAQ,EAAE,cAAc,CAAC,MAAM,EAAE,UAAU,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC;AACxF,CAAC;AAED,6FAA6F;AAC7F,KAAK,UAAU,OAAO,CAAC,QAAsB,EAAE,OAAwB,EAAE,SAAiB;IACxF,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;IACjE,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC9B,OAAO,EAAE,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACzD,CAAC;IACD,MAAM,IAAI,GAAG,aAAa,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3C,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IAC3D,OAAO;QACL,GAAG,GAAG,CAAC,IAAI,CAAC;QACZ,GAAG,CAAC,OAAO,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC;QAC/E,GAAG,CAAC,OAAO,CAAC,iBAAiB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,OAAO,CAAC,iBAAiB,EAAE,CAAC;KACrG,CAAC;AACJ,CAAC;AAcD;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,MAAqB,EAAE,IAAkB,EAAE,UAA8B,EAAE;IACrG,MAAM,EAAE,GAAG,EAAE,GAAG,WAAW,EAAE,GAAG,OAAO,CAAC;IACxC,MAAM,UAAU,GAAG,YAAY,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IACnD,IAAI,CAAC,UAAU,CAAC,EAAE;QAAE,OAAO,EAAE,GAAG,GAAG,CAAC,uBAAuB,CAAC,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC;IAC1F,MAAM,MAAM,GAAG,sBAAsB,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC;IACnD,IAAI,UAAU,CAAC,KAAK,CAAC,IAAI,KAAK,kBAAkB,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,EAAE,GAAG,GAAG,CAAC,qBAAqB,CAAC,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC;IAC9I,OAAO,EAAE,CAAC,cAAc,CAAC,MAAM,EAAE,UAAU,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,aAAa,GAA8D;IAC/E,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,qBAAqB;IAClC,OAAO,EAAE,0BAA0B;IACnC,cAAc,EAAE,wBAAwB;CACzC,CAAC;AAIF;;;GAGG;AACH,KAAK,UAAU,aAAa,CAAC,OAAoB,EAAE,IAAe,EAAE,MAAqB;IACvF,IAAI,OAAO,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CAAC,8FAA8F,CAAC,CAAC;IAClH,CAAC;IACD,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QACpB,OAAO,CAAC,KAAK,CAAC,4BAA4B,sBAAsB,cAAc,MAAM,CAAC,6BAA6B,CAAC,wCAAwC,CAAC,CAAC;QAC7J,OAAO,EAAE,KAAK,EAAE,qBAAqB,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC;IAC1E,CAAC;IACD,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,YAAY,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IAC3H,CAAC;IAAC,MAAM,CAAC;QACP,+EAA+E;QAC/E,OAAO,EAAE,KAAK,EAAE,qBAAqB,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC;IAC5E,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,SAAS,cAAc,CAAC,MAAqB,EAAE,IAAe,EAAE,MAAqB;IACnF,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;IACpD,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,OAAO,EAAE,GAAG,IAAI,CAAC;IACxE,MAAM,QAAQ,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,IAAI,OAAO,IAAI,IAAI,EAAE,cAAc,EAAE,CAAC;IACnF,IAAI,IAAI,KAAK,kBAAkB,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,EAAE,GAAG,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAC;IACvF,MAAM,KAAK,GAAG,qBAAqB,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;IAC7D,+EAA+E;IAC/E,MAAM,IAAI,GAAI,cAAc,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAqB,CAAC,MAAM,CAAC;IAC1G,OAAO;QACL,GAAG,QAAQ;QACX,EAAE,EAAE,IAAI,CAAC,EAAE;QACX,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC;QAC3C,IAAI,EAAE,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC;QACvE,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,GAAG,yBAAyB,CAAC,KAAK,CAAC,EAAE;KAClE,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,KAAK,UAAU,YAAY,CAAC,QAAsB,EAAE,OAA4C,EAAE,SAAiB;IACjH,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,IAAI,KAAgD,CAAC;IACrD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAkB,CAAC,OAAO,EAAE,EAAE;QACvD,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YACtB,UAAU,CAAC,KAAK,CAAC,IAAI,YAAY,CAAC,8CAA8C,EAAE,cAAc,CAAC,CAAC,CAAC;YACnG,OAAO,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;QACrC,CAAC,EAAE,SAAS,CAAC,CAAC;IAChB,CAAC,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,QAAQ;SACrB,IAAI,CAAC,OAAO,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC;SAC5C,IAAI,CAAC,WAAW,EAAE,GAAoB,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC;IACzE,IAAI,CAAC;QACH,OAAO,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IAChD,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;AACH,CAAC;AAED,kGAAkG;AAClG,SAAS,WAAW,CAAC,OAAgB;IACnC,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IACtF,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,EAAE,iBAAiB,EAAE,GAAG,OAAgG,CAAC;IACvJ,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACtB,OAAO,OAAO,EAAE,KAAK,QAAQ,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IAC1F,CAAC;IACD,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,EAAE,CAAC;QACvE,OAAO;YACL,MAAM,EAAE,MAA+B;YACvC,GAAG,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACnD,GAAG,CAAC,YAAY,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAClE,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;AACnC,CAAC;AAED,6EAA6E;AAC7E,SAAS,YAAY,CAAC,KAAc;IAClC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;AAChF,CAAC;AAED,SAAS,YAAY,CAAC,KAAc;IAClC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI,GAAG,CAAC;AAC9F,CAAC;AAED;;;GAGG;AACH,SAAS,UAAU,CACjB,QAAsB,EACtB,IAAsB,EACtB,OAAmG;IAEnG,MAAM,KAAK,GAAG,CAAC,YAAY,QAAQ,CAAC,IAAI,EAAE,EAAE,UAAU,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,EAAE,UAAU,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACnK,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,UAAU,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACnF,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,SAAS,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;IACtE,OAAO,CAAC,KAAK,CAAC,wBAAwB,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;AAC3D,CAAC"}
package/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "mailing",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "dependsOn": { "jobs": "*?" },
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.2.1",
3
+ "version": "0.2.3",
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",
@@ -1,7 +1,7 @@
1
1
  // The `softure-mail` bin. It loads the app's config through `@softure-ai/core/cli`, like
2
2
  // `softure migrate` (@softure-ai/db): the file must be loadable by Node itself (`.js`/`.mjs`, or `.ts`
3
3
  // where Node strips types). Anything else uses an app script with `runMailCli` instead (see run.ts).
4
- import { loadAppConfig, takeConfigOption } from "@softure-ai/core/cli";
4
+ import { loadAppConfig, takeConfigOption, type DatabaseRequirement } from "@softure-ai/core/cli";
5
5
  import { EXIT_FAILED, EXIT_USAGE, MAIL_USAGE, runMailCli, type CliOutput } from "./run.js";
6
6
 
7
7
  export interface RunMailCommandOptions {
@@ -29,7 +29,7 @@ export async function runMailCommand(options: RunMailCommandOptions): Promise<nu
29
29
  const { configPath, argv } = taken;
30
30
  if (!needsConfig(argv) && configPath === undefined) return runMailCli({ argv, cwd: options.cwd, output });
31
31
 
32
- const loaded = await loadAppConfig({ cwd: options.cwd, configPath, appScript: APP_SCRIPT });
32
+ const loaded = await loadAppConfig({ cwd: options.cwd, configPath, appScript: APP_SCRIPT, database: getDatabaseRequirement(argv) });
33
33
  if (!loaded.ok) {
34
34
  output.error(`softure-mail: ${loaded.problem}`);
35
35
  return EXIT_FAILED;
@@ -42,3 +42,11 @@ function needsConfig(argv: readonly string[]): boolean {
42
42
  if (argv.length === 0 || argv.includes("--help")) return false;
43
43
  return !(argv[0] === "dns" && argv.some((arg) => arg === "--domain" || arg.startsWith("--domain=")));
44
44
  }
45
+
46
+ /**
47
+ * `test` and `dns` never need a connection, so a missing or empty database URL loads as `database: null` (a CI job
48
+ * with only the provider key and the unsubscribe secret); the commands that connect keep refusing such a config.
49
+ */
50
+ function getDatabaseRequirement(argv: readonly string[]): DatabaseRequirement {
51
+ return argv[0] === "test" || argv[0] === "dns" ? "optional" : "required";
52
+ }
package/src/cli/run.ts CHANGED
@@ -20,7 +20,7 @@ import { checkSenderDns, getSenderDomain, resendReturnPath, type DmarcAlignment,
20
20
  import { checkImportedDeliveries, importDeliveries, type ImportedDelivery, type ImportProblem } from "../server/import-deliveries.js";
21
21
  import { maskAddress, redactUnsubscribeSignatures } from "../server/operator-output.js";
22
22
  import { getMailingOptions, resolveMailKind } from "../server/options.js";
23
- import { previewMail, sendMail } from "../server/send-mail.js";
23
+ import { previewMail, sendMail, sendMailUnchecked } from "../server/send-mail.js";
24
24
  import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "../server/unsubscribe-link.js";
25
25
 
26
26
  export interface CliOutput {
@@ -85,7 +85,8 @@ test Sends one mail to testAddress of the mailing options, and to no one el
85
85
  campaign in <content-file> (without the ledger: it can be sent again), or a short
86
86
  fixed test mail.
87
87
  --kind <kind> the mail's kind (default: the file's, else transactional); list kinds
88
- get the unsubscribe footer and headers
88
+ get the unsubscribe footer and headers, and are refused for a test address
89
+ that unsubscribed; without a database that check is skipped
89
90
  --preview print the mail instead of sending it
90
91
 
91
92
  import Writes deliveries the app made before it adopted the module into the ledger, so
@@ -687,10 +688,8 @@ async function runTest(command: Extract<Command, { kind: "test" }>, options: Run
687
688
  return EXIT_OK;
688
689
  }
689
690
  if (kind === TRANSACTIONAL_KIND) return reportTestSend(await sendMail({ config }, mail), { masked, kind }, output);
690
- if (config.database === null) {
691
- output.error("softure-mail test: list mail needs the database (its suppression list); set database.url in softure.config");
692
- return EXIT_FAILED;
693
- }
691
+ // The test address is the operator's own: without a database (CI, an operator machine) its suppression check guards nothing.
692
+ if (config.database === null) return reportTestSend(await sendMailUnchecked(config, mail), { masked, kind, unchecked: true }, output);
694
693
  let opened: CommandDatabase;
695
694
  try {
696
695
  opened = await openDatabase(config.database, options.openDatabase);
@@ -705,9 +704,10 @@ async function runTest(command: Extract<Command, { kind: "test" }>, options: Run
705
704
  }
706
705
  }
707
706
 
708
- function reportTestSend(result: Awaited<ReturnType<typeof sendMail>>, sent: { readonly masked: string; readonly kind: string }, output: CliOutput): number {
707
+ function reportTestSend(result: Awaited<ReturnType<typeof sendMail>>, sent: { readonly masked: string; readonly kind: string; readonly unchecked?: boolean }, output: CliOutput): number {
709
708
  if (result.ok) {
710
- output.log(`test mail (${sent.kind}) sent to ${sent.masked}; provider message id ${result.value.id}`);
709
+ const note = sent.unchecked === true ? "; no database, so the suppression list was not checked" : "";
710
+ output.log(`test mail (${sent.kind}) sent to ${sent.masked}; provider message id ${result.value.id}${note}`);
711
711
  return EXIT_OK;
712
712
  }
713
713
  const status = result.httpStatus === undefined ? "" : ` (HTTP ${String(result.httpStatus)})`;
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export const MODULE_ID = "mailing";
17
17
  export const mailing = defineModule({
18
18
  manifest: {
19
19
  id: MODULE_ID,
20
- version: "0.2.1",
20
+ version: "0.2.3",
21
21
  dependsOn: { jobs: "*?" },
22
22
  dbSchema: "mailing",
23
23
  tables: ["suppressions", "campaigns", "deliveries"],
@@ -1,34 +1,18 @@
1
1
  "use server";
2
2
 
3
- // The unsubscribe page's server action. It takes the signed link from the form (the same values
4
- // the page got in its URL), records the opt-out and redirects to the page's outcome. No session is
5
- // involved: the signature is the authorization. Next refuses an action whose Origin does not match
6
- // the host.
7
- import { errorLogLabel } from "@softure-ai/core";
3
+ // The unsubscribe server action, for an app whose own form posts to it: it takes the signed link
4
+ // from the form body, records the opt-out and redirects to the page's outcome. The package page
5
+ // does not use it: a server action posts to the page's URL, which carries the link, so the page's
6
+ // form posts to the one-click route's bare path instead (route.ts). Next refuses an action whose
7
+ // Origin does not match the host.
8
8
  import { getSoftureConfig } from "@softure-ai/core/next";
9
9
  import { redirect } from "next/navigation";
10
- import { getMailingRoutes } from "../server/options.js";
11
- import { unsubscribe } from "../server/suppressions.js";
12
- import { getUnsubscribeLinkParams, readUnsubscribeLink } from "../server/unsubscribe-link.js";
10
+ import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
13
11
  import { getMailingContext } from "./context.js";
14
- import { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
12
+ import { submitPageUnsubscribe } from "./page-submit.js";
15
13
 
16
14
  export async function unsubscribeAction(formData: FormData): Promise<void> {
17
15
  const config = getSoftureConfig();
18
16
  const link = readUnsubscribeLink(formData, config);
19
- let status: UnsubscribeStatus;
20
- try {
21
- const result = await unsubscribe(await getMailingContext(config), link, "page");
22
- status = result.ok ? "done" : "invalid";
23
- } catch (error) {
24
- // The label names the kind of failure, never its text: that can carry the link's values.
25
- console.error(`@softure-ai/mailing: unsubscribe failed: ${errorLogLabel(error)}`);
26
- status = "failed";
27
- }
28
- const query = new URLSearchParams({ [UNSUBSCRIBE_STATUS_PARAM]: status });
29
- if (status === "failed" && link !== null) {
30
- // The form comes back with the same link, so the person can try again.
31
- for (const [name, value] of Object.entries(getUnsubscribeLinkParams(link))) query.set(name, value);
32
- }
33
- redirect(`${getMailingRoutes(config).unsubscribe}?${query.toString()}`);
17
+ redirect(await submitPageUnsubscribe({ config, link, getContext: () => getMailingContext(config) }));
34
18
  }
@@ -0,0 +1,35 @@
1
+ // The page's submit, shared by the route (the package page's form posts there) and the server
2
+ // action (for an app's own form): record the opt-out and name the page's outcome to send the
3
+ // browser to. No session is involved: the signature is the authorization.
4
+ import { errorLogLabel, type SoftureConfig } from "@softure-ai/core";
5
+ import { getMailingRoutes } from "../server/options.js";
6
+ import { unsubscribe, type SuppressionContext } from "../server/suppressions.js";
7
+ import { getUnsubscribeLinkParams, type UnsubscribeLink } from "../server/unsubscribe-link.js";
8
+ import { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
9
+
10
+ export interface PageSubmit {
11
+ readonly config: SoftureConfig;
12
+ readonly link: UnsubscribeLink | null;
13
+ /** Opens the database; a failure there is a failed submit like any other. */
14
+ readonly getContext: () => Promise<SuppressionContext>;
15
+ }
16
+
17
+ /** Records the link from the page and returns the page's outcome URL (a path with its query). */
18
+ export async function submitPageUnsubscribe({ config, link, getContext }: PageSubmit): Promise<string> {
19
+ let status: UnsubscribeStatus;
20
+ try {
21
+ const result = await unsubscribe(await getContext(), link, "page");
22
+ status = result.ok ? "done" : "invalid";
23
+ } catch (error) {
24
+ // The label names the kind of failure, never its text: that can carry the link's values.
25
+ console.error(`@softure-ai/mailing: unsubscribe failed: ${errorLogLabel(error)}`);
26
+ status = "failed";
27
+ }
28
+ const query = new URLSearchParams({ [UNSUBSCRIBE_STATUS_PARAM]: status });
29
+ if (status === "failed" && link !== null) {
30
+ // The form comes back with the same link, so the person can try again; the page strips it
31
+ // from the address bar again.
32
+ for (const [name, value] of Object.entries(getUnsubscribeLinkParams(link))) query.set(name, value);
33
+ }
34
+ return `${getMailingRoutes(config).unsubscribe}?${query.toString()}`;
35
+ }
@@ -1,17 +1,20 @@
1
1
  // The unsubscribe page, ready to mount with one line:
2
2
  // `export { UnsubscribePage as default } from "@softure-ai/mailing/next"`.
3
- // A server component without client JavaScript: the button is a plain form posting a server
4
- // action, so it works in any browser a mail client opens. Opening the page changes nothing (mail
5
- // scanners open links too) and does not check the signature: the submit does. It takes the
3
+ // A server component: the button is a plain form posting the link, in hidden fields, to the
4
+ // one-click route's bare path (route.ts), so it works without JavaScript in any browser a mail
5
+ // client opens and the signed token never sits in a POST's request line. With JavaScript a small
6
+ // client component also takes the link out of the address bar. Opening the page changes nothing
7
+ // (mail scanners open links too) and does not check the signature: the submit does. It takes the
6
8
  // module's signed links and, with `mailing({ legacyUnsubscribe })`, the app's older ones: mount it
7
9
  // at the old links' path too.
8
10
  import { getSoftureConfig } from "@softure-ai/core/next";
9
11
  import { Button, Card, createSlotClassGetter, FormError, type ClassNames } from "@softure-ai/ui";
10
12
  import type { ComponentType, ReactNode } from "react";
13
+ import { getMailingRoutes } from "../server/options.js";
11
14
  import { getUnsubscribeLinkParams, readUnsubscribeLink } from "../server/unsubscribe-link.js";
12
- import { unsubscribeAction } from "./actions.js";
13
15
  import { getMailingMessages } from "./messages.js";
14
16
  import { UNSUBSCRIBE_STATUS_PARAM } from "./params.js";
17
+ import { StripLinkQuery } from "./strip-link-query.js";
15
18
 
16
19
  type SearchParams = Promise<Record<string, string | string[] | undefined>>;
17
20
 
@@ -111,7 +114,8 @@ async function renderUnsubscribePage(searchParams: SearchParams | undefined, opt
111
114
  const submit = slot("submit");
112
115
  return (
113
116
  <UnsubscribeLayout title={messages.title} lead={messages.lead} slot={slot} options={options}>
114
- <form action={unsubscribeAction} className={slot("form")}>
117
+ <StripLinkQuery names={Object.keys(linkParams)} />
118
+ <form action={getMailingRoutes(config).oneClick} method="post" className={slot("form")}>
115
119
  {Object.entries(linkParams).map(([name, value]) => (
116
120
  <input key={name} type="hidden" name={name} value={value} />
117
121
  ))}
package/src/next/route.ts CHANGED
@@ -8,27 +8,44 @@
8
8
  // purpose: a few provider addresses act for millions of people, so a bucket would refuse real
9
9
  // unsubscribes. The link is checked by shape and signature before the database is touched, and
10
10
  // the only write is an idempotent insert.
11
- import { errorLogLabel } from "@softure-ai/core";
11
+ //
12
+ // The package page's form posts here too, to the bare path with the link in its body, so the
13
+ // signed token never sits in a POST's request line (a server action would post to the page's URL,
14
+ // query included). No Origin check, as for a mail client: the signature is the authorization.
15
+ import { errorLogLabel, type SoftureConfig } from "@softure-ai/core";
12
16
  import { getSoftureConfig } from "@softure-ai/core/next";
13
17
  import { getMailingOptions, getMailingRoutes } from "../server/options.js";
14
18
  import { unsubscribe } from "../server/suppressions.js";
15
- import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
19
+ import { readUnsubscribeLink, type UnsubscribeLink } from "../server/unsubscribe-link.js";
16
20
  import { getMailingContext } from "./context.js";
21
+ import { submitPageUnsubscribe } from "./page-submit.js";
17
22
 
18
23
  const NO_STORE = { "cache-control": "no-store" };
19
24
 
20
25
  /**
21
- * Records the opt-out of the link in the URL. The body (`List-Unsubscribe=One-Click`) is not read:
26
+ * Records the opt-out of the link in the URL. Its body (`List-Unsubscribe=One-Click`) is not read:
22
27
  * anyone can send it, the signature is the proof. 200 once recorded (also again), 400 for a link
23
28
  * that does not verify (200 with `mailing({ oneClickInvalidLinkStatus: 200 })`, so the answer never
24
29
  * tells whether a token is live), 500 when the database fails: the client may retry, and 200 would
25
30
  * claim an opt-out that did not happen. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
26
31
  * way; mount this route at the old link's path too.
32
+ *
33
+ * A POST without a link in the URL is the unsubscribe page's form: its link comes from the form
34
+ * body, and the answer is a 303 to the page's outcome (`?status=done`, `invalid` or `failed`).
27
35
  */
28
36
  export async function postUnsubscribeRoute(request: Request): Promise<Response> {
37
+ const config = getSoftureConfig();
38
+ const { searchParams } = new URL(request.url);
39
+ if (readUnsubscribeLink(searchParams, config) === null) {
40
+ const formLink = await readFormLink(request, config);
41
+ if (formLink !== null) {
42
+ const location = await submitPageUnsubscribe({ config, link: formLink, getContext: () => getMailingContext(config) });
43
+ return new Response(null, { status: 303, headers: { ...NO_STORE, location } });
44
+ }
45
+ }
29
46
  try {
30
47
  const ctx = await getMailingContext();
31
- const link = readUnsubscribeLink(new URL(request.url).searchParams, ctx.config);
48
+ const link = readUnsubscribeLink(searchParams, ctx.config);
32
49
  const result = await unsubscribe(ctx, link, "one-click");
33
50
  return new Response(null, { status: result.ok ? 200 : getMailingOptions(ctx.config).oneClickInvalidLinkStatus, headers: NO_STORE });
34
51
  } catch (error) {
@@ -46,3 +63,41 @@ export function getUnsubscribeRoute(request: Request): Response {
46
63
  const { search } = new URL(request.url);
47
64
  return new Response(null, { status: 303, headers: { ...NO_STORE, location: `${getMailingRoutes(getSoftureConfig()).unsubscribe}${search}` } });
48
65
  }
66
+
67
+ /**
68
+ * The page's form is URL-encoded and small (a signed link is about 100 bytes; a legacy one at most a few 512-character
69
+ * values). A larger body is no page submit, and is not read past this size.
70
+ */
71
+ const MAX_FORM_BYTES = 8192;
72
+ const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
73
+
74
+ /** The link in a URL-encoded form body, or null for any other body. */
75
+ async function readFormLink(request: Request, config: SoftureConfig): Promise<UnsubscribeLink | null> {
76
+ if (request.headers.get("content-type")?.toLowerCase().startsWith(FORM_CONTENT_TYPE) !== true) return null;
77
+ const body = await readBoundedText(request, MAX_FORM_BYTES);
78
+ return body === null ? null : readUnsubscribeLink(new URLSearchParams(body), config);
79
+ }
80
+
81
+ /** The body as text, or null when it is longer than `maxBytes` (the rest is not read) or cannot be read. */
82
+ async function readBoundedText(request: Request, maxBytes: number): Promise<string | null> {
83
+ if (request.body === null) return "";
84
+ const reader = request.body.getReader();
85
+ const chunks: Uint8Array[] = [];
86
+ let size = 0;
87
+ try {
88
+ for (;;) {
89
+ const { done, value } = await reader.read();
90
+ if (done) break;
91
+ size += value.byteLength;
92
+ if (size > maxBytes) {
93
+ await reader.cancel();
94
+ return null;
95
+ }
96
+ chunks.push(value);
97
+ }
98
+ } catch {
99
+ // A client that drops the connection mid-body: no form to read.
100
+ return null;
101
+ }
102
+ return Buffer.concat(chunks).toString("utf8");
103
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ // Takes the unsubscribe link's parameters out of the address bar once the page has loaded, so the
4
+ // signed token does not stay in the tab's URL and the browser history. It renders nothing; without
5
+ // JavaScript the address bar keeps the link, and the form still works.
6
+ import { useEffect } from "react";
7
+
8
+ export interface StripLinkQueryProps {
9
+ /** The query parameters that carry the link. */
10
+ readonly names: readonly string[];
11
+ }
12
+
13
+ export function StripLinkQuery({ names }: StripLinkQueryProps) {
14
+ useEffect(() => {
15
+ stripQueryParams(names);
16
+ }, [names]);
17
+ return null;
18
+ }
19
+
20
+ /**
21
+ * Replaces the current history entry with the URL minus `names`. The current `history.state` goes
22
+ * along: it carries Next's own marker, so its router lets the call through without a navigation.
23
+ */
24
+ export function stripQueryParams(names: readonly string[]): void {
25
+ const url = new URL(location.href);
26
+ const present = names.filter((name) => url.searchParams.has(name));
27
+ if (present.length === 0) return;
28
+ for (const name of present) url.searchParams.delete(name);
29
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
30
+ }
@@ -59,7 +59,32 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
59
59
  }
60
60
  }
61
61
 
62
- const outcome = await callProvider(provider, composeMessage(context.config, valid, secret), timeoutMs);
62
+ return deliver(provider, composeMessage(context.config, valid, secret), timeoutMs);
63
+ }
64
+
65
+ /**
66
+ * `sendMail` without the suppression check, for `softure-mail test` without a database: its recipient is always the
67
+ * operator's own `testAddress`. List mail still needs the secret. Internal: not exported from `/server`, so no app
68
+ * code can send list mail to a real recipient past the suppression list.
69
+ */
70
+ export async function sendMailUnchecked(config: SoftureConfig, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
71
+ const { provider, timeoutMs } = getMailingOptions(config);
72
+ const validation = validateMail(mail, options);
73
+ if (!validation.ok) {
74
+ logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
75
+ return err("mailing.invalid_input");
76
+ }
77
+ const secret = readUnsubscribeSecrets().current;
78
+ if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) {
79
+ logFailure(provider, "mailing.unavailable", { cause: "no_unsubscribe_secret" });
80
+ return err("mailing.unavailable");
81
+ }
82
+ return deliver(provider, composeMessage(config, validation.value, secret), timeoutMs);
83
+ }
84
+
85
+ /** Hands `message` to the provider and maps its outcome to the result, logging a failure. */
86
+ async function deliver(provider: MailProvider, message: ProviderMessage, timeoutMs: number): Promise<SendMailResult> {
87
+ const outcome = await callProvider(provider, message, timeoutMs);
63
88
  if (outcome.status === "sent") {
64
89
  return ok({ id: outcome.id, provider: provider.name });
65
90
  }