@softure-ai/mailing 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
4
4
  production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
5
5
  "Definition of done"). Versions before the first one below are described in their GitHub Releases (`mailing@x.y.z`).
6
6
 
7
+ ## 0.2.2
8
+
9
+ - The unsubscribe page no longer posts to the URL that carries the signed token (#556). Its form sends the link in
10
+ hidden fields to the one-click route's bare path (`routes.oneClick`); `postUnsubscribeRoute` records a POST
11
+ whose URL has no link and whose URL-encoded body (at most 8 KiB) has one as a `page` opt-out and answers with a
12
+ 303 to the page's outcome (`/unsubscribe?status=done`, `invalid` or `failed`), as the server action did. A mail
13
+ client's RFC 8058 one-click POST (link in the URL) is unchanged. With JavaScript the page also strips the link's
14
+ parameters from the address bar after load (`history.replaceState`); without it the page still works.
15
+ - `unsubscribeAction` is unchanged and still exported, but the package page no longer uses it: a server action
16
+ posts to the page's URL, query included.
17
+ - Migrate an app:
18
+ 1. Using the package page and route (`UnsubscribePage` or `createUnsubscribePage`, `postUnsubscribeRoute` on
19
+ `routes.oneClick`): nothing to change. The route must stay mounted and public, as before.
20
+ 2. An app that kept its own unsubscribe page only to move the token out of the URL (hidden fields plus
21
+ `history.replaceState`): delete that page and mount the package page instead
22
+ (`export { UnsubscribePage as default } from "@softure-ai/mailing/next"`, or `createUnsubscribePage({ Layout })`
23
+ for the app's frame).
24
+ 3. An app with its own form posting to `unsubscribeAction`: point the form at the route instead
25
+ (`<form action="/api/mailing/unsubscribe" method="post">` with the link's `r` and `t`, or the legacy
26
+ parameters, as hidden fields) to keep the token out of the POST URL.
27
+
7
28
  ## 0.2.1
8
29
 
9
30
  - New entry `@softure-ai/mailing/jobs` (#492), with `@softure-ai/jobs` as an optional peer dependency:
package/README.md CHANGED
@@ -583,6 +583,16 @@ function AppShell({ title, lead, children }: UnsubscribeLayoutProps) {
583
583
  export default createUnsubscribePage({ Layout: AppShell, classNames: { submit: "w-full" } });
584
584
  ```
585
585
 
586
+ **The token stays out of request lines.** The page's form posts the link in hidden fields to the
587
+ route's bare path (`routes.oneClick`), which records it as a `page` opt-out and answers with a 303
588
+ to the page's outcome (`?status=done`, `invalid` or `failed`). The route tells the two callers
589
+ apart by where the link is: in the URL, a mail client's one-click POST; only in a URL-encoded body
590
+ (at most 8 KiB), the page's form. With JavaScript the page also takes the link out of the address
591
+ bar once loaded (`history.replaceState`), so it does not stay in the tab's history. The page sets
592
+ `<meta name="referrer" content="same-origin">`, so no other site sees it in a `Referer`.
593
+ `unsubscribeAction` stays exported for an app's own form, but a server action posts to the page's
594
+ URL, query included: prefer posting to the route as the package page does.
595
+
586
596
  Both must stay public: keep them out of the auth guard's `protect` list. The route has no per-IP
587
597
  rate limit on purpose: mail providers post from a few shared addresses that act for millions of
588
598
  people, so a bucket would refuse real unsubscribes; the signature is checked before any database
@@ -651,7 +661,8 @@ None.
651
661
 
652
662
  ## 8. Appearance
653
663
 
654
- The unsubscribe page is a `Card` from `@softure-ai/ui` with a plain form (no client JavaScript),
664
+ The unsubscribe page is a `Card` from `@softure-ai/ui` with a plain form (it works without client
665
+ JavaScript; one small client component only cleans the address bar),
655
666
  styled by the app's tokens. `createUnsubscribePage({ classNames, unstyled, Layout })` adds the app's
656
667
  classes or its own layout (section 4).
657
668
 
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export const MODULE_ID = "mailing";
15
15
  export const mailing = defineModule({
16
16
  manifest: {
17
17
  id: MODULE_ID,
18
- version: "0.2.1",
18
+ version: "0.2.2",
19
19
  dependsOn: { jobs: "*?" },
20
20
  dbSchema: "mailing",
21
21
  tables: ["suppressions", "campaigns", "deliveries"],
@@ -1 +1 @@
1
- {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/next/actions.ts"],"names":[],"mappings":"AAeA,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBzE"}
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/next/actions.ts"],"names":[],"mappings":"AAaA,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAIzE"}
@@ -1,35 +1,17 @@
1
1
  "use server";
2
- // The unsubscribe page's server action. It takes the signed link from the form (the same values
3
- // the page got in its URL), records the opt-out and redirects to the page's outcome. No session is
4
- // involved: the signature is the authorization. Next refuses an action whose Origin does not match
5
- // the host.
6
- import { errorLogLabel } from "@softure-ai/core";
2
+ // The unsubscribe server action, for an app whose own form posts to it: it takes the signed link
3
+ // from the form body, records the opt-out and redirects to the page's outcome. The package page
4
+ // does not use it: a server action posts to the page's URL, which carries the link, so the page's
5
+ // form posts to the one-click route's bare path instead (route.ts). Next refuses an action whose
6
+ // Origin does not match the host.
7
7
  import { getSoftureConfig } from "@softure-ai/core/next";
8
8
  import { redirect } from "next/navigation";
9
- import { getMailingRoutes } from "../server/options.js";
10
- import { unsubscribe } from "../server/suppressions.js";
11
- import { getUnsubscribeLinkParams, readUnsubscribeLink } from "../server/unsubscribe-link.js";
9
+ import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
12
10
  import { getMailingContext } from "./context.js";
13
- import { UNSUBSCRIBE_STATUS_PARAM } from "./params.js";
11
+ import { submitPageUnsubscribe } from "./page-submit.js";
14
12
  export async function unsubscribeAction(formData) {
15
13
  const config = getSoftureConfig();
16
14
  const link = readUnsubscribeLink(formData, config);
17
- let status;
18
- try {
19
- const result = await unsubscribe(await getMailingContext(config), link, "page");
20
- status = result.ok ? "done" : "invalid";
21
- }
22
- catch (error) {
23
- // The label names the kind of failure, never its text: that can carry the link's values.
24
- console.error(`@softure-ai/mailing: unsubscribe failed: ${errorLogLabel(error)}`);
25
- status = "failed";
26
- }
27
- const query = new URLSearchParams({ [UNSUBSCRIBE_STATUS_PARAM]: status });
28
- if (status === "failed" && link !== null) {
29
- // The form comes back with the same link, so the person can try again.
30
- for (const [name, value] of Object.entries(getUnsubscribeLinkParams(link)))
31
- query.set(name, value);
32
- }
33
- redirect(`${getMailingRoutes(config).unsubscribe}?${query.toString()}`);
15
+ redirect(await submitPageUnsubscribe({ config, link, getContext: () => getMailingContext(config) }));
34
16
  }
35
17
  //# sourceMappingURL=actions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"actions.js","sourceRoot":"","sources":["../../src/next/actions.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,gGAAgG;AAChG,mGAAmG;AACnG,mGAAmG;AACnG,YAAY;AACZ,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AACxD,OAAO,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAC9F,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,wBAAwB,EAA0B,MAAM,aAAa,CAAC;AAE/E,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,QAAkB;IACxD,MAAM,MAAM,GAAG,gBAAgB,EAAE,CAAC;IAClC,MAAM,IAAI,GAAG,mBAAmB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACnD,IAAI,MAAyB,CAAC;IAC9B,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,MAAM,iBAAiB,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;QAChF,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,yFAAyF;QACzF,OAAO,CAAC,KAAK,CAAC,4CAA4C,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAClF,MAAM,GAAG,QAAQ,CAAC;IACpB,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,eAAe,CAAC,EAAE,CAAC,wBAAwB,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;IAC1E,IAAI,MAAM,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QACzC,uEAAuE;QACvE,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,wBAAwB,CAAC,IAAI,CAAC,CAAC;YAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IACrG,CAAC;IACD,QAAQ,CAAC,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC,WAAW,IAAI,KAAK,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;AAC1E,CAAC"}
1
+ {"version":3,"file":"actions.js","sourceRoot":"","sources":["../../src/next/actions.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,iGAAiG;AACjG,gGAAgG;AAChG,kGAAkG;AAClG,iGAAiG;AACjG,kCAAkC;AAClC,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AACpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,QAAkB;IACxD,MAAM,MAAM,GAAG,gBAAgB,EAAE,CAAC;IAClC,MAAM,IAAI,GAAG,mBAAmB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACnD,QAAQ,CAAC,MAAM,qBAAqB,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC,iBAAiB,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC;AACvG,CAAC"}
@@ -0,0 +1,12 @@
1
+ import { type SoftureConfig } from "@softure-ai/core";
2
+ import { type SuppressionContext } from "../server/suppressions.js";
3
+ import { type UnsubscribeLink } from "../server/unsubscribe-link.js";
4
+ export interface PageSubmit {
5
+ readonly config: SoftureConfig;
6
+ readonly link: UnsubscribeLink | null;
7
+ /** Opens the database; a failure there is a failed submit like any other. */
8
+ readonly getContext: () => Promise<SuppressionContext>;
9
+ }
10
+ /** Records the link from the page and returns the page's outcome URL (a path with its query). */
11
+ export declare function submitPageUnsubscribe({ config, link, getContext }: PageSubmit): Promise<string>;
12
+ //# sourceMappingURL=page-submit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-submit.d.ts","sourceRoot":"","sources":["../../src/next/page-submit.ts"],"names":[],"mappings":"AAGA,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAErE,OAAO,EAAe,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACjF,OAAO,EAA4B,KAAK,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAG/F,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,CAAC;CACxD;AAED,iGAAiG;AACjG,wBAAsB,qBAAqB,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAiBrG"}
@@ -0,0 +1,30 @@
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 } from "@softure-ai/core";
5
+ import { getMailingRoutes } from "../server/options.js";
6
+ import { unsubscribe } from "../server/suppressions.js";
7
+ import { getUnsubscribeLinkParams } from "../server/unsubscribe-link.js";
8
+ import { UNSUBSCRIBE_STATUS_PARAM } from "./params.js";
9
+ /** Records the link from the page and returns the page's outcome URL (a path with its query). */
10
+ export async function submitPageUnsubscribe({ config, link, getContext }) {
11
+ let status;
12
+ try {
13
+ const result = await unsubscribe(await getContext(), link, "page");
14
+ status = result.ok ? "done" : "invalid";
15
+ }
16
+ catch (error) {
17
+ // The label names the kind of failure, never its text: that can carry the link's values.
18
+ console.error(`@softure-ai/mailing: unsubscribe failed: ${errorLogLabel(error)}`);
19
+ status = "failed";
20
+ }
21
+ const query = new URLSearchParams({ [UNSUBSCRIBE_STATUS_PARAM]: status });
22
+ if (status === "failed" && link !== null) {
23
+ // The form comes back with the same link, so the person can try again; the page strips it
24
+ // from the address bar again.
25
+ for (const [name, value] of Object.entries(getUnsubscribeLinkParams(link)))
26
+ query.set(name, value);
27
+ }
28
+ return `${getMailingRoutes(config).unsubscribe}?${query.toString()}`;
29
+ }
30
+ //# sourceMappingURL=page-submit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-submit.js","sourceRoot":"","sources":["../../src/next/page-submit.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,6FAA6F;AAC7F,0EAA0E;AAC1E,OAAO,EAAE,aAAa,EAAsB,MAAM,kBAAkB,CAAC;AACrE,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAAE,WAAW,EAA2B,MAAM,2BAA2B,CAAC;AACjF,OAAO,EAAE,wBAAwB,EAAwB,MAAM,+BAA+B,CAAC;AAC/F,OAAO,EAAE,wBAAwB,EAA0B,MAAM,aAAa,CAAC;AAS/E,iGAAiG;AACjG,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAc;IAClF,IAAI,MAAyB,CAAC;IAC9B,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,MAAM,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;QACnE,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,yFAAyF;QACzF,OAAO,CAAC,KAAK,CAAC,4CAA4C,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAClF,MAAM,GAAG,QAAQ,CAAC;IACpB,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,eAAe,CAAC,EAAE,CAAC,wBAAwB,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;IAC1E,IAAI,MAAM,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QACzC,0FAA0F;QAC1F,8BAA8B;QAC9B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,wBAAwB,CAAC,IAAI,CAAC,CAAC;YAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IACrG,CAAC;IACD,OAAO,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC,WAAW,IAAI,KAAK,CAAC,QAAQ,EAAE,EAAE,CAAC;AACvE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"pages.d.ts","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"AAQA,OAAO,EAAkD,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjG,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAMtD,KAAK,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC,CAAC;AAE3E,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,CAAC;CACtC;AAED,uGAAuG;AACvG,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEtE,gGAAgG;AAChG,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,sBAAsB;IACrC,0EAA0E;IAC1E,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC,mBAAmB,CAAC,CAAC;IACtD,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC,sBAAsB,CAAC,CAAC;CACzD;AA2CD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,GAAE,sBAA2B,IACvB,kBAAkB,oBAAoB,0CAGxF;AAED,wBAAsB,eAAe,CAAC,EAAE,YAAY,EAAE,EAAE,oBAAoB,wCAE3E"}
1
+ {"version":3,"file":"pages.d.ts","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"AAUA,OAAO,EAAkD,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjG,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAOtD,KAAK,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC,CAAC;AAE3E,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,CAAC;CACtC;AAED,uGAAuG;AACvG,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEtE,gGAAgG;AAChG,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,sBAAsB;IACrC,0EAA0E;IAC1E,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC,mBAAmB,CAAC,CAAC;IACtD,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC,sBAAsB,CAAC,CAAC;CACzD;AA2CD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,GAAE,sBAA2B,IACvB,kBAAkB,oBAAoB,0CAGxF;AAED,wBAAsB,eAAe,CAAC,EAAE,YAAY,EAAE,EAAE,oBAAoB,wCAE3E"}
@@ -1,17 +1,20 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The unsubscribe page, ready to mount with one line:
3
3
  // `export { UnsubscribePage as default } from "@softure-ai/mailing/next"`.
4
- // A server component without client JavaScript: the button is a plain form posting a server
5
- // action, so it works in any browser a mail client opens. Opening the page changes nothing (mail
6
- // scanners open links too) and does not check the signature: the submit does. It takes the
4
+ // A server component: the button is a plain form posting the link, in hidden fields, to the
5
+ // one-click route's bare path (route.ts), so it works without JavaScript in any browser a mail
6
+ // client opens and the signed token never sits in a POST's request line. With JavaScript a small
7
+ // client component also takes the link out of the address bar. Opening the page changes nothing
8
+ // (mail scanners open links too) and does not check the signature: the submit does. It takes the
7
9
  // module's signed links and, with `mailing({ legacyUnsubscribe })`, the app's older ones: mount it
8
10
  // at the old links' path too.
9
11
  import { getSoftureConfig } from "@softure-ai/core/next";
10
12
  import { Button, Card, createSlotClassGetter, FormError } from "@softure-ai/ui";
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
  const DEFAULT_CLASSES = {
16
19
  root: "sft:mx-auto sft:box-border sft:w-full sft:sm:max-w-md sft:px-4 sft:py-4",
17
20
  card: "",
@@ -60,6 +63,6 @@ async function renderUnsubscribePage(searchParams, options) {
60
63
  return _jsx(UnsubscribeLayout, { title: messages.invalidTitle, lead: messages.invalidBody, slot: slot, options: options });
61
64
  }
62
65
  const submit = slot("submit");
63
- return (_jsx(UnsubscribeLayout, { title: messages.title, lead: messages.lead, slot: slot, options: options, children: _jsxs("form", { action: unsubscribeAction, className: slot("form"), children: [Object.entries(linkParams).map(([name, value]) => (_jsx("input", { type: "hidden", name: name, value: value }, name))), _jsx(FormError, { message: status === "failed" ? messages.failed : undefined, unstyled: options.unstyled }), _jsx("div", { children: _jsx(Button, { type: "submit", variant: "primary", unstyled: options.unstyled, ...(submit === undefined ? {} : { classNames: { root: submit } }), children: messages.submit }) })] }) }));
66
+ return (_jsxs(UnsubscribeLayout, { title: messages.title, lead: messages.lead, slot: slot, options: options, children: [_jsx(StripLinkQuery, { names: Object.keys(linkParams) }), _jsxs("form", { action: getMailingRoutes(config).oneClick, method: "post", className: slot("form"), children: [Object.entries(linkParams).map(([name, value]) => (_jsx("input", { type: "hidden", name: name, value: value }, name))), _jsx(FormError, { message: status === "failed" ? messages.failed : undefined, unstyled: options.unstyled }), _jsx("div", { children: _jsx(Button, { type: "submit", variant: "primary", unstyled: options.unstyled, ...(submit === undefined ? {} : { classNames: { root: submit } }), children: messages.submit }) })] })] }));
64
67
  }
65
68
  //# sourceMappingURL=pages.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"pages.js","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pages.js","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"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"}
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Records the opt-out of the link in the URL. The body (`List-Unsubscribe=One-Click`) is not read:
2
+ * Records the opt-out of the link in the URL. Its body (`List-Unsubscribe=One-Click`) is not read:
3
3
  * anyone can send it, the signature is the proof. 200 once recorded (also again), 400 for a link
4
4
  * that does not verify (200 with `mailing({ oneClickInvalidLinkStatus: 200 })`, so the answer never
5
5
  * tells whether a token is live), 500 when the database fails: the client may retry, and 200 would
6
6
  * claim an opt-out that did not happen. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
7
7
  * way; mount this route at the old link's path too.
8
+ *
9
+ * A POST without a link in the URL is the unsubscribe page's form: its link comes from the form
10
+ * body, and the answer is a 303 to the page's outcome (`?status=done`, `invalid` or `failed`).
8
11
  */
9
12
  export declare function postUnsubscribeRoute(request: Request): Promise<Response>;
10
13
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/next/route.ts"],"names":[],"mappings":"AAmBA;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAU9E;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,CAG9D"}
1
+ {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/next/route.ts"],"names":[],"mappings":"AAwBA;;;;;;;;;;GAUG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAmB9E;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,CAG9D"}
@@ -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"}
package/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "mailing",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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.2",
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",
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.2",
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
+ }