@growth-labs/mailer 0.6.3 → 0.6.5

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.
@@ -1,4 +1,21 @@
1
1
  import type { APIRoute } from 'astro';
2
- export declare const GET: APIRoute;
3
- export declare const POST: APIRoute;
2
+ import type { Subscriber } from '../types.js';
3
+ export type UnsubscribeSubscriber = Pick<Subscriber, 'id' | 'email' | 'name'>;
4
+ export interface UnsubscribeRouteHooks {
5
+ /** Called only after the existing token verification succeeds. Read-only. */
6
+ lookupSubscriber?: (subscriberId: string) => Promise<UnsubscribeSubscriber | null>;
7
+ /**
8
+ * Commit the unsubscribe and return the subscriber ID used by the existing
9
+ * confirmation send and preferences page. Useful when a consumer keeps a
10
+ * canonical consent store and a legacy delivery/preferences mirror.
11
+ * Called only on POST, never on GET.
12
+ */
13
+ commitUnsubscribe?: (subscriber: UnsubscribeSubscriber) => Promise<string>;
14
+ }
15
+ /** Reuse the signed route, confirmation UI and one-click protocol with a consumer's subscriber store. */
16
+ export declare function createUnsubscribeHandlers(hooks?: UnsubscribeRouteHooks): {
17
+ GET: APIRoute;
18
+ POST: APIRoute;
19
+ };
20
+ export declare const GET: APIRoute, POST: APIRoute;
4
21
  //# sourceMappingURL=unsubscribe.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"unsubscribe.d.ts","sourceRoot":"","sources":["../../src/routes/unsubscribe.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAgFrC,eAAO,MAAM,GAAG,EAAE,QA6BjB,CAAA;AAGD,eAAO,MAAM,IAAI,EAAE,QAkBlB,CAAA"}
1
+ {"version":3,"file":"unsubscribe.d.ts","sourceRoot":"","sources":["../../src/routes/unsubscribe.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAGrC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAmB7C,MAAM,MAAM,qBAAqB,GAAG,IAAI,CAAC,UAAU,EAAE,IAAI,GAAG,OAAO,GAAG,MAAM,CAAC,CAAA;AAE7E,MAAM,WAAW,qBAAqB;IACrC,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAA;IAClF;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,CAAC,UAAU,EAAE,qBAAqB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC1E;AAED,yGAAyG;AACzG,wBAAgB,yBAAyB,CAAC,KAAK,GAAE,qBAA0B,GAAG;IAC7E,GAAG,EAAE,QAAQ,CAAA;IACb,IAAI,EAAE,QAAQ,CAAA;CACd,CAwIA;AAED,eAAO,MAAQ,GAAG,YAAE,IAAI,UAAgC,CAAA"}
@@ -6,109 +6,142 @@ import { emitMailerAnalyticsEvent } from '../utils/analytics.js';
6
6
  import { resolveSigningSecret, runtimeSecretErrorResponse, siteHost, } from '../utils/runtime-secrets.js';
7
7
  import { sendTransactional } from '../utils/send.js';
8
8
  import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js';
9
+ import { renderEmail } from '../utils/templates.js';
9
10
  import { generateToken, verifyToken } from '../utils/tokens.js';
10
11
  import { buildSiteUrl } from '../utils/urls.js';
11
- async function processUnsubscribe(token, context, request) {
12
- const bindingsEnv = cloudflareEnv;
13
- const requestUrl = request?.url ?? context?.url.toString() ?? config.siteUrl;
14
- const signingSecret = await resolveSigningSecret(bindingsEnv, config, requestUrl);
15
- if (!signingSecret.ok)
16
- return { secretFailure: signingSecret };
17
- // Verify token
18
- const payload = await verifyToken(signingSecret.secret, token, siteHost(config.siteUrl));
19
- if (!payload || payload.action !== 'unsubscribe') {
20
- return { error: 'Invalid or expired token', status: 400 };
21
- }
22
- // Resolve bindings
23
- const d1 = bindingsEnv[config.d1Binding];
24
- // Schema probe — short-circuit with a Response on miss; both GET and POST
25
- // handlers below forward it unchanged.
26
- const schema = await probeMailerSchema(d1, config.d1Binding);
27
- if (!schema.ok)
28
- return { schemaMissing: true };
29
- const db = drizzle(d1);
30
- // Get subscriber
31
- const subscriber = await getSubscriberById(db, payload.subscriberId);
32
- if (!subscriber) {
33
- return { error: 'Subscriber not found', status: 404 };
12
+ const unsubscribeHeaders = {
13
+ 'Cache-Control': 'private, no-store',
14
+ 'Referrer-Policy': 'no-referrer',
15
+ 'X-Robots-Tag': 'noindex, nofollow',
16
+ };
17
+ /** Reuse the signed route, confirmation UI and one-click protocol with a consumer's subscriber store. */
18
+ export function createUnsubscribeHandlers(hooks = {}) {
19
+ async function resolveUnsubscribe(context) {
20
+ const { url, request } = context;
21
+ const token = url.searchParams.get('token');
22
+ if (!token) {
23
+ return {
24
+ ok: false,
25
+ response: new Response('Missing token', { status: 400, headers: unsubscribeHeaders }),
26
+ };
27
+ }
28
+ const bindingsEnv = cloudflareEnv;
29
+ const signingSecret = await resolveSigningSecret(bindingsEnv, config, request.url);
30
+ if (!signingSecret.ok)
31
+ return { ok: false, response: runtimeSecretErrorResponse(signingSecret) };
32
+ const payload = await verifyToken(signingSecret.secret, token, siteHost(config.siteUrl));
33
+ if (!payload || payload.action !== 'unsubscribe') {
34
+ return {
35
+ ok: false,
36
+ response: new Response('Invalid or expired token', {
37
+ status: 400,
38
+ headers: unsubscribeHeaders,
39
+ }),
40
+ };
41
+ }
42
+ const d1 = bindingsEnv[config.d1Binding];
43
+ const schema = await probeMailerSchema(d1, config.d1Binding);
44
+ if (!schema.ok)
45
+ return { ok: false, response: schemaMissingResponse() };
46
+ const db = drizzle(d1);
47
+ const subscriber = hooks.lookupSubscriber
48
+ ? await hooks.lookupSubscriber(payload.subscriberId)
49
+ : await getSubscriberById(db, payload.subscriberId);
50
+ if (!subscriber) {
51
+ return {
52
+ ok: false,
53
+ response: new Response('Subscriber not found', {
54
+ status: 404,
55
+ headers: unsubscribeHeaders,
56
+ }),
57
+ };
58
+ }
59
+ return { ok: true, db, subscriber, signingSecret: signingSecret.secret, token };
34
60
  }
35
- // Unsubscribe
36
- await unsubscribeSubscriber(db, payload.subscriberId);
37
- // Send confirmation email — pass the full Worker env so config.d1Binding,
38
- // config.queueBinding, and config.sendsD1Binding all resolve correctly.
39
- await sendTransactional(bindingsEnv, config, {
40
- to: subscriber.email,
41
- subscriberId: subscriber.id,
42
- subject: `You've been unsubscribed from ${config.senderName}`,
43
- template: 'unsubscribe-confirm',
44
- data: {
45
- name: subscriber.name,
46
- resubscribeUrl: buildSiteUrl(config.siteUrl, config.subscribePath),
47
- },
48
- });
49
- emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
50
- request,
51
- context,
52
- contentSlug: subscriber.id,
53
- label: {
54
- subscriberId: subscriber.id,
55
- email: subscriber.email,
56
- },
57
- });
58
- return {
59
- success: true,
60
- subscriberId: payload.subscriberId,
61
- signingSecret: signingSecret.secret,
61
+ // GET: Link scanners may fetch this URL. Only POST may change consent.
62
+ const GET = async (context) => {
63
+ const result = await resolveUnsubscribe(context);
64
+ if (!result.ok)
65
+ return result.response;
66
+ const actionUrl = buildSiteUrl(config.siteUrl, config.unsubscribePath, {
67
+ token: result.token,
68
+ confirm: true,
69
+ });
70
+ return new Response(renderEmail(`<!doctype html>
71
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
72
+ <title>Unsubscribe — {{senderName}}</title></head>
73
+ <body><main><h1>Unsubscribe</h1><form method="post" action="{{actionUrl}}"><button type="submit">Confirm</button></form></main></body></html>`, {
74
+ senderName: config.senderName,
75
+ siteUrl: config.siteUrl,
76
+ unsubscribeUrl: actionUrl,
77
+ preferencesUrl: '',
78
+ brand: config.brand,
79
+ actionUrl,
80
+ }), {
81
+ status: 200,
82
+ headers: {
83
+ ...unsubscribeHeaders,
84
+ // no-referrer makes native form POSTs send Origin: null. Keep the
85
+ // token out of Referer without breaking the host's CSRF check.
86
+ 'Referrer-Policy': 'strict-origin',
87
+ 'Content-Type': 'text/html; charset=utf-8',
88
+ },
89
+ });
90
+ };
91
+ // POST: the explicit confirmation or RFC 8058 List-Unsubscribe-Post.
92
+ const POST = async (context) => {
93
+ const result = await resolveUnsubscribe(context);
94
+ if (!result.ok)
95
+ return result.response;
96
+ const { db, subscriber, signingSecret } = result;
97
+ const { request, url } = context;
98
+ const bindingsEnv = cloudflareEnv;
99
+ let confirmationSubscriberId;
100
+ if (hooks.commitUnsubscribe) {
101
+ confirmationSubscriberId = await hooks.commitUnsubscribe(subscriber);
102
+ }
103
+ else {
104
+ await unsubscribeSubscriber(db, subscriber.id);
105
+ confirmationSubscriberId = subscriber.id;
106
+ }
107
+ // Pass the full Worker env so custom/split DB and queue bindings still work.
108
+ await sendTransactional(bindingsEnv, config, {
109
+ to: subscriber.email,
110
+ subscriberId: confirmationSubscriberId,
111
+ subject: `You've been unsubscribed from ${config.senderName}`,
112
+ template: 'unsubscribe-confirm',
113
+ data: {
114
+ name: subscriber.name,
115
+ resubscribeUrl: buildSiteUrl(config.siteUrl, config.subscribePath),
116
+ },
117
+ });
118
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
119
+ request,
120
+ context,
121
+ contentSlug: subscriber.id,
122
+ label: { subscriberId: subscriber.id, email: subscriber.email },
123
+ });
124
+ if (url.searchParams.get('confirm') === 'true') {
125
+ const preferencesToken = await generateToken(signingSecret, {
126
+ subscriberId: confirmationSubscriberId,
127
+ action: 'preferences',
128
+ host: siteHost(config.siteUrl),
129
+ });
130
+ return new Response(null, {
131
+ status: 303,
132
+ headers: {
133
+ ...unsubscribeHeaders,
134
+ Location: buildSiteUrl(config.siteUrl, config.preferencesPath, {
135
+ token: preferencesToken,
136
+ unsubscribed: true,
137
+ }),
138
+ },
139
+ });
140
+ }
141
+ // RFC 8058 one-click requests require no cookies and never redirect.
142
+ return new Response('OK', { status: 200, headers: unsubscribeHeaders });
62
143
  };
144
+ return { GET, POST };
63
145
  }
64
- // GET: Link from email footer
65
- export const GET = async (context) => {
66
- const { url, request } = context;
67
- const token = url.searchParams.get('token');
68
- if (!token) {
69
- return Response.json({ error: 'Missing token' }, { status: 400 });
70
- }
71
- const result = await processUnsubscribe(token, context, request);
72
- if (result.secretFailure)
73
- return runtimeSecretErrorResponse(result.secretFailure);
74
- if ('schemaMissing' in result)
75
- return schemaMissingResponse();
76
- if ('error' in result) {
77
- return Response.json({ error: result.error }, { status: result.status });
78
- }
79
- // Generate preferences token for the redirect
80
- const preferencesToken = await generateToken(result.signingSecret, {
81
- subscriberId: result.subscriberId,
82
- action: 'preferences',
83
- host: siteHost(config.siteUrl),
84
- });
85
- const preferencesUrl = buildSiteUrl(config.siteUrl, config.preferencesPath, {
86
- token: preferencesToken,
87
- unsubscribed: true,
88
- });
89
- return new Response(null, {
90
- status: 302,
91
- headers: { Location: preferencesUrl },
92
- });
93
- };
94
- // POST: RFC 8058 List-Unsubscribe-Post
95
- export const POST = async (context) => {
96
- const { request } = context;
97
- // RFC 8058: body is "List-Unsubscribe=One-Click"
98
- // Token comes from List-Unsubscribe header URL
99
- const url = new URL(request.url);
100
- const token = url.searchParams.get('token');
101
- if (!token) {
102
- return new Response('Missing token', { status: 400 });
103
- }
104
- const result = await processUnsubscribe(token, context, request);
105
- if (result.secretFailure)
106
- return runtimeSecretErrorResponse(result.secretFailure);
107
- if ('schemaMissing' in result)
108
- return schemaMissingResponse();
109
- if ('error' in result) {
110
- return new Response(result.error, { status: result.status });
111
- }
112
- return new Response('OK', { status: 200 });
113
- };
146
+ export const { GET, POST } = createUnsubscribeHandlers();
114
147
  //# sourceMappingURL=unsubscribe.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"unsubscribe.js","sourceRoot":"","sources":["../../src/routes/unsubscribe.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"unsubscribe.js","sourceRoot":"","sources":["../../src/routes/unsubscribe.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/mailer",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -3,6 +3,7 @@ import { config } from 'virtual:growth-labs/mailer/config'
3
3
  import type { APIRoute } from 'astro'
4
4
  import { drizzle } from 'drizzle-orm/d1'
5
5
  import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
6
+ import type { Subscriber } from '../types.js'
6
7
  import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
7
8
  import {
8
9
  resolveSigningSecret,
@@ -11,123 +12,170 @@ import {
11
12
  } from '../utils/runtime-secrets.js'
12
13
  import { sendTransactional } from '../utils/send.js'
13
14
  import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js'
15
+ import { renderEmail } from '../utils/templates.js'
14
16
  import { generateToken, verifyToken } from '../utils/tokens.js'
15
17
  import { buildSiteUrl } from '../utils/urls.js'
16
18
 
17
- async function processUnsubscribe(
18
- token: string,
19
- context?: Parameters<APIRoute>[0],
20
- request?: Request,
21
- ) {
22
- const bindingsEnv = cloudflareEnv as Record<string, unknown>
23
- const requestUrl = request?.url ?? context?.url.toString() ?? config.siteUrl
24
- const signingSecret = await resolveSigningSecret(bindingsEnv, config, requestUrl)
25
- if (!signingSecret.ok) return { secretFailure: signingSecret }
26
-
27
- // Verify token
28
- const payload = await verifyToken(signingSecret.secret, token, siteHost(config.siteUrl))
29
- if (!payload || payload.action !== 'unsubscribe') {
30
- return { error: 'Invalid or expired token', status: 400 }
31
- }
32
-
33
- // Resolve bindings
34
- const d1 = bindingsEnv[config.d1Binding] as D1Database
35
-
36
- // Schema probe — short-circuit with a Response on miss; both GET and POST
37
- // handlers below forward it unchanged.
38
- const schema = await probeMailerSchema(d1, config.d1Binding)
39
- if (!schema.ok) return { schemaMissing: true as const }
40
-
41
- const db = drizzle(d1)
42
-
43
- // Get subscriber
44
- const subscriber = await getSubscriberById(db, payload.subscriberId)
45
- if (!subscriber) {
46
- return { error: 'Subscriber not found', status: 404 }
47
- }
48
-
49
- // Unsubscribe
50
- await unsubscribeSubscriber(db, payload.subscriberId)
51
-
52
- // Send confirmation email — pass the full Worker env so config.d1Binding,
53
- // config.queueBinding, and config.sendsD1Binding all resolve correctly.
54
- await sendTransactional(bindingsEnv, config, {
55
- to: subscriber.email,
56
- subscriberId: subscriber.id,
57
- subject: `You've been unsubscribed from ${config.senderName}`,
58
- template: 'unsubscribe-confirm',
59
- data: {
60
- name: subscriber.name,
61
- resubscribeUrl: buildSiteUrl(config.siteUrl, config.subscribePath),
62
- },
63
- })
64
-
65
- emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
66
- request,
67
- context,
68
- contentSlug: subscriber.id,
69
- label: {
70
- subscriberId: subscriber.id,
71
- email: subscriber.email,
72
- },
73
- })
19
+ const unsubscribeHeaders = {
20
+ 'Cache-Control': 'private, no-store',
21
+ 'Referrer-Policy': 'no-referrer',
22
+ 'X-Robots-Tag': 'noindex, nofollow',
23
+ }
74
24
 
75
- return {
76
- success: true,
77
- subscriberId: payload.subscriberId,
78
- signingSecret: signingSecret.secret,
79
- }
25
+ export type UnsubscribeSubscriber = Pick<Subscriber, 'id' | 'email' | 'name'>
26
+
27
+ export interface UnsubscribeRouteHooks {
28
+ /** Called only after the existing token verification succeeds. Read-only. */
29
+ lookupSubscriber?: (subscriberId: string) => Promise<UnsubscribeSubscriber | null>
30
+ /**
31
+ * Commit the unsubscribe and return the subscriber ID used by the existing
32
+ * confirmation send and preferences page. Useful when a consumer keeps a
33
+ * canonical consent store and a legacy delivery/preferences mirror.
34
+ * Called only on POST, never on GET.
35
+ */
36
+ commitUnsubscribe?: (subscriber: UnsubscribeSubscriber) => Promise<string>
80
37
  }
81
38
 
82
- // GET: Link from email footer
83
- export const GET: APIRoute = async (context) => {
84
- const { url, request } = context
85
- const token = url.searchParams.get('token')
86
- if (!token) {
87
- return Response.json({ error: 'Missing token' }, { status: 400 })
39
+ /** Reuse the signed route, confirmation UI and one-click protocol with a consumer's subscriber store. */
40
+ export function createUnsubscribeHandlers(hooks: UnsubscribeRouteHooks = {}): {
41
+ GET: APIRoute
42
+ POST: APIRoute
43
+ } {
44
+ async function resolveUnsubscribe(context: Parameters<APIRoute>[0]) {
45
+ const { url, request } = context
46
+ const token = url.searchParams.get('token')
47
+ if (!token) {
48
+ return {
49
+ ok: false as const,
50
+ response: new Response('Missing token', { status: 400, headers: unsubscribeHeaders }),
51
+ }
52
+ }
53
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
54
+ const signingSecret = await resolveSigningSecret(bindingsEnv, config, request.url)
55
+ if (!signingSecret.ok)
56
+ return { ok: false as const, response: runtimeSecretErrorResponse(signingSecret) }
57
+
58
+ const payload = await verifyToken(signingSecret.secret, token, siteHost(config.siteUrl))
59
+ if (!payload || payload.action !== 'unsubscribe') {
60
+ return {
61
+ ok: false as const,
62
+ response: new Response('Invalid or expired token', {
63
+ status: 400,
64
+ headers: unsubscribeHeaders,
65
+ }),
66
+ }
67
+ }
68
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
69
+ const schema = await probeMailerSchema(d1, config.d1Binding)
70
+ if (!schema.ok) return { ok: false as const, response: schemaMissingResponse() }
71
+ const db = drizzle(d1)
72
+ const subscriber = hooks.lookupSubscriber
73
+ ? await hooks.lookupSubscriber(payload.subscriberId)
74
+ : await getSubscriberById(db, payload.subscriberId)
75
+ if (!subscriber) {
76
+ return {
77
+ ok: false as const,
78
+ response: new Response('Subscriber not found', {
79
+ status: 404,
80
+ headers: unsubscribeHeaders,
81
+ }),
82
+ }
83
+ }
84
+ return { ok: true as const, db, subscriber, signingSecret: signingSecret.secret, token }
88
85
  }
89
86
 
90
- const result = await processUnsubscribe(token, context, request)
91
- if (result.secretFailure) return runtimeSecretErrorResponse(result.secretFailure)
92
- if ('schemaMissing' in result) return schemaMissingResponse()
93
- if ('error' in result) {
94
- return Response.json({ error: result.error }, { status: result.status })
87
+ // GET: Link scanners may fetch this URL. Only POST may change consent.
88
+ const GET: APIRoute = async (context) => {
89
+ const result = await resolveUnsubscribe(context)
90
+ if (!result.ok) return result.response
91
+
92
+ const actionUrl = buildSiteUrl(config.siteUrl, config.unsubscribePath, {
93
+ token: result.token,
94
+ confirm: true,
95
+ })
96
+ return new Response(
97
+ renderEmail(
98
+ `<!doctype html>
99
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
100
+ <title>Unsubscribe — {{senderName}}</title></head>
101
+ <body><main><h1>Unsubscribe</h1><form method="post" action="{{actionUrl}}"><button type="submit">Confirm</button></form></main></body></html>`,
102
+ {
103
+ senderName: config.senderName,
104
+ siteUrl: config.siteUrl,
105
+ unsubscribeUrl: actionUrl,
106
+ preferencesUrl: '',
107
+ brand: config.brand,
108
+ actionUrl,
109
+ },
110
+ ),
111
+ {
112
+ status: 200,
113
+ headers: {
114
+ ...unsubscribeHeaders,
115
+ // no-referrer makes native form POSTs send Origin: null. Keep the
116
+ // token out of Referer without breaking the host's CSRF check.
117
+ 'Referrer-Policy': 'strict-origin',
118
+ 'Content-Type': 'text/html; charset=utf-8',
119
+ },
120
+ },
121
+ )
95
122
  }
96
123
 
97
- // Generate preferences token for the redirect
98
- const preferencesToken = await generateToken(result.signingSecret, {
99
- subscriberId: result.subscriberId,
100
- action: 'preferences',
101
- host: siteHost(config.siteUrl),
102
- })
103
- const preferencesUrl = buildSiteUrl(config.siteUrl, config.preferencesPath, {
104
- token: preferencesToken,
105
- unsubscribed: true,
106
- })
107
-
108
- return new Response(null, {
109
- status: 302,
110
- headers: { Location: preferencesUrl },
111
- })
112
- }
113
-
114
- // POST: RFC 8058 List-Unsubscribe-Post
115
- export const POST: APIRoute = async (context) => {
116
- const { request } = context
117
- // RFC 8058: body is "List-Unsubscribe=One-Click"
118
- // Token comes from List-Unsubscribe header URL
119
- const url = new URL(request.url)
120
- const token = url.searchParams.get('token')
121
- if (!token) {
122
- return new Response('Missing token', { status: 400 })
123
- }
124
-
125
- const result = await processUnsubscribe(token, context, request)
126
- if (result.secretFailure) return runtimeSecretErrorResponse(result.secretFailure)
127
- if ('schemaMissing' in result) return schemaMissingResponse()
128
- if ('error' in result) {
129
- return new Response(result.error, { status: result.status })
124
+ // POST: the explicit confirmation or RFC 8058 List-Unsubscribe-Post.
125
+ const POST: APIRoute = async (context) => {
126
+ const result = await resolveUnsubscribe(context)
127
+ if (!result.ok) return result.response
128
+ const { db, subscriber, signingSecret } = result
129
+ const { request, url } = context
130
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
131
+
132
+ let confirmationSubscriberId: string
133
+ if (hooks.commitUnsubscribe) {
134
+ confirmationSubscriberId = await hooks.commitUnsubscribe(subscriber)
135
+ } else {
136
+ await unsubscribeSubscriber(db, subscriber.id)
137
+ confirmationSubscriberId = subscriber.id
138
+ }
139
+ // Pass the full Worker env so custom/split DB and queue bindings still work.
140
+ await sendTransactional(bindingsEnv, config, {
141
+ to: subscriber.email,
142
+ subscriberId: confirmationSubscriberId,
143
+ subject: `You've been unsubscribed from ${config.senderName}`,
144
+ template: 'unsubscribe-confirm',
145
+ data: {
146
+ name: subscriber.name,
147
+ resubscribeUrl: buildSiteUrl(config.siteUrl, config.subscribePath),
148
+ },
149
+ })
150
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
151
+ request,
152
+ context,
153
+ contentSlug: subscriber.id,
154
+ label: { subscriberId: subscriber.id, email: subscriber.email },
155
+ })
156
+
157
+ if (url.searchParams.get('confirm') === 'true') {
158
+ const preferencesToken = await generateToken(signingSecret, {
159
+ subscriberId: confirmationSubscriberId,
160
+ action: 'preferences',
161
+ host: siteHost(config.siteUrl),
162
+ })
163
+ return new Response(null, {
164
+ status: 303,
165
+ headers: {
166
+ ...unsubscribeHeaders,
167
+ Location: buildSiteUrl(config.siteUrl, config.preferencesPath, {
168
+ token: preferencesToken,
169
+ unsubscribed: true,
170
+ }),
171
+ },
172
+ })
173
+ }
174
+ // RFC 8058 one-click requests require no cookies and never redirect.
175
+ return new Response('OK', { status: 200, headers: unsubscribeHeaders })
130
176
  }
131
177
 
132
- return new Response('OK', { status: 200 })
178
+ return { GET, POST }
133
179
  }
180
+
181
+ export const { GET, POST } = createUnsubscribeHandlers()