@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 +21 -0
- package/README.md +12 -1
- package/dist/index.js +1 -1
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +8 -26
- package/dist/next/actions.js.map +1 -1
- package/dist/next/page-submit.d.ts +12 -0
- package/dist/next/page-submit.d.ts.map +1 -0
- package/dist/next/page-submit.js +30 -0
- package/dist/next/page-submit.js.map +1 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +8 -5
- package/dist/next/pages.js.map +1 -1
- package/dist/next/route.d.ts +4 -1
- package/dist/next/route.d.ts.map +1 -1
- package/dist/next/route.js +58 -2
- package/dist/next/route.js.map +1 -1
- package/dist/next/strip-link-query.d.ts +11 -0
- package/dist/next/strip-link-query.d.ts.map +1 -0
- package/dist/next/strip-link-query.js +25 -0
- package/dist/next/strip-link-query.js.map +1 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/next/actions.ts +8 -24
- package/src/next/page-submit.ts +35 -0
- package/src/next/pages.tsx +9 -5
- package/src/next/route.ts +59 -4
- package/src/next/strip-link-query.tsx +30 -0
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 (
|
|
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.
|
|
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":"
|
|
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"}
|
package/dist/next/actions.js
CHANGED
|
@@ -1,35 +1,17 @@
|
|
|
1
1
|
"use server";
|
|
2
|
-
// The unsubscribe
|
|
3
|
-
// the
|
|
4
|
-
//
|
|
5
|
-
// the
|
|
6
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
package/dist/next/actions.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actions.js","sourceRoot":"","sources":["../../src/next/actions.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,gGAAgG;AAChG,
|
|
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"}
|
package/dist/next/pages.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pages.d.ts","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"
|
|
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"}
|
package/dist/next/pages.js
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 (
|
|
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
|
package/dist/next/pages.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pages.js","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":";AAAA,sDAAsD;AACtD,2EAA2E;AAC3E,4FAA4F;AAC5F,iGAAiG;AACjG,
|
|
1
|
+
{"version":3,"file":"pages.js","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"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"}
|
package/dist/next/route.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Records the opt-out of the link in the URL.
|
|
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
|
/**
|
package/dist/next/route.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/next/route.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/next/route.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
package/dist/next/route.js.map
CHANGED
|
@@ -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,
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softure-ai/mailing",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
20
|
+
version: "0.2.2",
|
|
21
21
|
dependsOn: { jobs: "*?" },
|
|
22
22
|
dbSchema: "mailing",
|
|
23
23
|
tables: ["suppressions", "campaigns", "deliveries"],
|
package/src/next/actions.ts
CHANGED
|
@@ -1,34 +1,18 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
|
-
// The unsubscribe
|
|
4
|
-
// the
|
|
5
|
-
//
|
|
6
|
-
// the
|
|
7
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/next/pages.tsx
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
<
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
+
}
|