@growth-labs/mailer 0.6.0 → 0.6.4
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/README.md +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/routes/unsubscribe.d.ts +19 -2
- package/dist/routes/unsubscribe.d.ts.map +1 -1
- package/dist/routes/unsubscribe.js +127 -100
- package/dist/routes/unsubscribe.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +12 -1
- package/src/routes/preferences.astro +2 -2
- package/src/routes/unsubscribe.ts +149 -107
package/README.md
CHANGED
|
@@ -14,6 +14,10 @@ Use `@growth-labs/mailer` (this package) for newsletter and campaign
|
|
|
14
14
|
workflows. Use `@growth-labs/email` for transactional one-off sends.
|
|
15
15
|
See the `@growth-labs/email` README for the full distinction.
|
|
16
16
|
|
|
17
|
+
Both integrations register the package's existing virtual config types through
|
|
18
|
+
Astro's `astro:config:done` type injection. No consumer-owned declaration is
|
|
19
|
+
needed for `virtual:growth-labs/mailer/config`; runtime route selection is unchanged.
|
|
20
|
+
|
|
17
21
|
## Config
|
|
18
22
|
|
|
19
23
|
```typescript
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAA;AAC7C,OAAO,KAAK,EAAE,uBAAuB,EAAE,aAAa,EAAyB,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAA;AAC7C,OAAO,KAAK,EAAE,uBAAuB,EAAE,aAAa,EAAyB,MAAM,cAAc,CAAA;AA4BjG,MAAM,CAAC,OAAO,UAAU,MAAM,CAAC,WAAW,EAAE,aAAa,GAAG,gBAAgB,CAkE3E;AAED,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,uBAAuB,GAAG,gBAAgB,CAWvF;AAED,YAAY,EACX,uBAAuB,EACvB,aAAa,EACb,+BAA+B,EAC/B,qBAAqB,GACrB,MAAM,cAAc,CAAA;AAErB,OAAO,EAAE,6BAA6B,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AACjF,YAAY,EACX,UAAU,EACV,aAAa,EACb,iBAAiB,EACjB,SAAS,EACT,eAAe,EACf,aAAa,EACb,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,GACZ,MAAM,YAAY,CAAA;AACnB,YAAY,EACX,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,GACnB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AACpD,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAC7E,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEhD,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAC7E,OAAO,EACN,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EACpB,iBAAiB,GACjB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -6,17 +6,24 @@ function isSourceEntrypoint() {
|
|
|
6
6
|
}
|
|
7
7
|
const ENTRYPOINT_EXTENSION = isSourceEntrypoint() ? '.ts' : '.js';
|
|
8
8
|
function resolveEntrypoint(path) {
|
|
9
|
-
if (path.endsWith('.astro')) {
|
|
9
|
+
if (path.endsWith('.astro') || path.endsWith('.d.ts')) {
|
|
10
10
|
const sourcePath = isSourceEntrypoint() ? path : `../src/${path.replace(/^\.\//, '')}`;
|
|
11
11
|
return fileURLToPath(new URL(sourcePath, import.meta.url));
|
|
12
12
|
}
|
|
13
13
|
return fileURLToPath(new URL(`${path}${ENTRYPOINT_EXTENSION}`, import.meta.url));
|
|
14
14
|
}
|
|
15
|
+
const injectMailerTypes = ({ injectTypes, }) => {
|
|
16
|
+
injectTypes({
|
|
17
|
+
filename: 'config.d.ts',
|
|
18
|
+
content: `/// <reference path="${resolveEntrypoint('./virtual.d.ts')}" />`,
|
|
19
|
+
});
|
|
20
|
+
};
|
|
15
21
|
export default function mailer(userOptions) {
|
|
16
22
|
const options = mailerOptionsSchema.parse(userOptions);
|
|
17
23
|
return {
|
|
18
24
|
name: '@growth-labs/mailer',
|
|
19
25
|
hooks: {
|
|
26
|
+
'astro:config:done': injectMailerTypes,
|
|
20
27
|
'astro:config:setup': ({ injectRoute, addMiddleware, updateConfig, config: astroConfig }) => {
|
|
21
28
|
// Auto-detect @growth-labs/analytics
|
|
22
29
|
const hasAnalytics = astroConfig.integrations.some((i) => i.name === '@growth-labs/analytics');
|
|
@@ -79,6 +86,7 @@ export function mailerComponents(userOptions) {
|
|
|
79
86
|
return {
|
|
80
87
|
name: '@growth-labs/mailer/components',
|
|
81
88
|
hooks: {
|
|
89
|
+
'astro:config:done': injectMailerTypes,
|
|
82
90
|
'astro:config:setup': ({ updateConfig }) => {
|
|
83
91
|
updateConfig({ vite: { plugins: [growthLabsMailerPlugin(options)] } });
|
|
84
92
|
},
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAGxC,OAAO,EAAE,6BAA6B,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AACjF,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAA;AAEzD,SAAS,kBAAkB;IAC1B,OAAO,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AAC9E,CAAC;AAED,MAAM,oBAAoB,GAAG,kBAAkB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAA;AAEjE,SAAS,iBAAiB,CAAC,IAAY;IACtC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
|
-
|
|
3
|
-
export
|
|
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;
|
|
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,CAkIA;AAED,eAAO,MAAQ,GAAG,YAAE,IAAI,UAAgC,CAAA"}
|
|
@@ -6,109 +6,136 @@ 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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: { ...unsubscribeHeaders, 'Content-Type': 'text/html; charset=utf-8' },
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
// POST: the explicit confirmation or RFC 8058 List-Unsubscribe-Post.
|
|
86
|
+
const POST = async (context) => {
|
|
87
|
+
const result = await resolveUnsubscribe(context);
|
|
88
|
+
if (!result.ok)
|
|
89
|
+
return result.response;
|
|
90
|
+
const { db, subscriber, signingSecret } = result;
|
|
91
|
+
const { request, url } = context;
|
|
92
|
+
const bindingsEnv = cloudflareEnv;
|
|
93
|
+
let confirmationSubscriberId;
|
|
94
|
+
if (hooks.commitUnsubscribe) {
|
|
95
|
+
confirmationSubscriberId = await hooks.commitUnsubscribe(subscriber);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
await unsubscribeSubscriber(db, subscriber.id);
|
|
99
|
+
confirmationSubscriberId = subscriber.id;
|
|
100
|
+
}
|
|
101
|
+
// Pass the full Worker env so custom/split DB and queue bindings still work.
|
|
102
|
+
await sendTransactional(bindingsEnv, config, {
|
|
103
|
+
to: subscriber.email,
|
|
104
|
+
subscriberId: confirmationSubscriberId,
|
|
105
|
+
subject: `You've been unsubscribed from ${config.senderName}`,
|
|
106
|
+
template: 'unsubscribe-confirm',
|
|
107
|
+
data: {
|
|
108
|
+
name: subscriber.name,
|
|
109
|
+
resubscribeUrl: buildSiteUrl(config.siteUrl, config.subscribePath),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
|
|
113
|
+
request,
|
|
114
|
+
context,
|
|
115
|
+
contentSlug: subscriber.id,
|
|
116
|
+
label: { subscriberId: subscriber.id, email: subscriber.email },
|
|
117
|
+
});
|
|
118
|
+
if (url.searchParams.get('confirm') === 'true') {
|
|
119
|
+
const preferencesToken = await generateToken(signingSecret, {
|
|
120
|
+
subscriberId: confirmationSubscriberId,
|
|
121
|
+
action: 'preferences',
|
|
122
|
+
host: siteHost(config.siteUrl),
|
|
123
|
+
});
|
|
124
|
+
return new Response(null, {
|
|
125
|
+
status: 303,
|
|
126
|
+
headers: {
|
|
127
|
+
...unsubscribeHeaders,
|
|
128
|
+
Location: buildSiteUrl(config.siteUrl, config.preferencesPath, {
|
|
129
|
+
token: preferencesToken,
|
|
130
|
+
unsubscribed: true,
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// RFC 8058 one-click requests require no cookies and never redirect.
|
|
136
|
+
return new Response('OK', { status: 200, headers: unsubscribeHeaders });
|
|
62
137
|
};
|
|
138
|
+
return { GET, POST };
|
|
63
139
|
}
|
|
64
|
-
|
|
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
|
-
};
|
|
140
|
+
export const { GET, POST } = createUnsubscribeHandlers();
|
|
114
141
|
//# sourceMappingURL=unsubscribe.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unsubscribe.js","sourceRoot":"","sources":["../../src/routes/unsubscribe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,IAAI,aAAa,EAAE,MAAM,oBAAoB,CAAA;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,mCAAmC,CAAA;AAE1D,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AACxC,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAA;
|
|
1
|
+
{"version":3,"file":"unsubscribe.js","sourceRoot":"","sources":["../../src/routes/unsubscribe.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ function isSourceEntrypoint(): boolean {
|
|
|
11
11
|
const ENTRYPOINT_EXTENSION = isSourceEntrypoint() ? '.ts' : '.js'
|
|
12
12
|
|
|
13
13
|
function resolveEntrypoint(path: string): string {
|
|
14
|
-
if (path.endsWith('.astro')) {
|
|
14
|
+
if (path.endsWith('.astro') || path.endsWith('.d.ts')) {
|
|
15
15
|
const sourcePath = isSourceEntrypoint() ? path : `../src/${path.replace(/^\.\//, '')}`
|
|
16
16
|
return fileURLToPath(new URL(sourcePath, import.meta.url))
|
|
17
17
|
}
|
|
@@ -19,12 +19,22 @@ function resolveEntrypoint(path: string): string {
|
|
|
19
19
|
return fileURLToPath(new URL(`${path}${ENTRYPOINT_EXTENSION}`, import.meta.url))
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const injectMailerTypes: NonNullable<AstroIntegration['hooks']['astro:config:done']> = ({
|
|
23
|
+
injectTypes,
|
|
24
|
+
}) => {
|
|
25
|
+
injectTypes({
|
|
26
|
+
filename: 'config.d.ts',
|
|
27
|
+
content: `/// <reference path="${resolveEntrypoint('./virtual.d.ts')}" />`,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export default function mailer(userOptions: MailerOptions): AstroIntegration {
|
|
23
32
|
const options = mailerOptionsSchema.parse(userOptions) as ResolvedMailerOptions
|
|
24
33
|
|
|
25
34
|
return {
|
|
26
35
|
name: '@growth-labs/mailer',
|
|
27
36
|
hooks: {
|
|
37
|
+
'astro:config:done': injectMailerTypes,
|
|
28
38
|
'astro:config:setup': ({ injectRoute, addMiddleware, updateConfig, config: astroConfig }) => {
|
|
29
39
|
// Auto-detect @growth-labs/analytics
|
|
30
40
|
const hasAnalytics = astroConfig.integrations.some(
|
|
@@ -92,6 +102,7 @@ export function mailerComponents(userOptions: MailerComponentsOptions): AstroInt
|
|
|
92
102
|
return {
|
|
93
103
|
name: '@growth-labs/mailer/components',
|
|
94
104
|
hooks: {
|
|
105
|
+
'astro:config:done': injectMailerTypes,
|
|
95
106
|
'astro:config:setup': ({ updateConfig }) => {
|
|
96
107
|
updateConfig({ vite: { plugins: [growthLabsMailerPlugin(options)] } })
|
|
97
108
|
},
|
|
@@ -67,8 +67,8 @@ if (!d1) {
|
|
|
67
67
|
}
|
|
68
68
|
const db = drizzle(d1);
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
if (
|
|
70
|
+
// Form data is parsed only for POST requests.
|
|
71
|
+
if (formData) {
|
|
72
72
|
const action = formData.get("action");
|
|
73
73
|
|
|
74
74
|
if (action === "unsubscribe") {
|
|
@@ -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,164 @@ 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
export
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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: { ...unsubscribeHeaders, 'Content-Type': 'text/html; charset=utf-8' },
|
|
114
|
+
},
|
|
115
|
+
)
|
|
95
116
|
}
|
|
96
117
|
|
|
97
|
-
//
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
118
|
+
// POST: the explicit confirmation or RFC 8058 List-Unsubscribe-Post.
|
|
119
|
+
const POST: APIRoute = async (context) => {
|
|
120
|
+
const result = await resolveUnsubscribe(context)
|
|
121
|
+
if (!result.ok) return result.response
|
|
122
|
+
const { db, subscriber, signingSecret } = result
|
|
123
|
+
const { request, url } = context
|
|
124
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
125
|
+
|
|
126
|
+
let confirmationSubscriberId: string
|
|
127
|
+
if (hooks.commitUnsubscribe) {
|
|
128
|
+
confirmationSubscriberId = await hooks.commitUnsubscribe(subscriber)
|
|
129
|
+
} else {
|
|
130
|
+
await unsubscribeSubscriber(db, subscriber.id)
|
|
131
|
+
confirmationSubscriberId = subscriber.id
|
|
132
|
+
}
|
|
133
|
+
// Pass the full Worker env so custom/split DB and queue bindings still work.
|
|
134
|
+
await sendTransactional(bindingsEnv, config, {
|
|
135
|
+
to: subscriber.email,
|
|
136
|
+
subscriberId: confirmationSubscriberId,
|
|
137
|
+
subject: `You've been unsubscribed from ${config.senderName}`,
|
|
138
|
+
template: 'unsubscribe-confirm',
|
|
139
|
+
data: {
|
|
140
|
+
name: subscriber.name,
|
|
141
|
+
resubscribeUrl: buildSiteUrl(config.siteUrl, config.subscribePath),
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
|
|
145
|
+
request,
|
|
146
|
+
context,
|
|
147
|
+
contentSlug: subscriber.id,
|
|
148
|
+
label: { subscriberId: subscriber.id, email: subscriber.email },
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
if (url.searchParams.get('confirm') === 'true') {
|
|
152
|
+
const preferencesToken = await generateToken(signingSecret, {
|
|
153
|
+
subscriberId: confirmationSubscriberId,
|
|
154
|
+
action: 'preferences',
|
|
155
|
+
host: siteHost(config.siteUrl),
|
|
156
|
+
})
|
|
157
|
+
return new Response(null, {
|
|
158
|
+
status: 303,
|
|
159
|
+
headers: {
|
|
160
|
+
...unsubscribeHeaders,
|
|
161
|
+
Location: buildSiteUrl(config.siteUrl, config.preferencesPath, {
|
|
162
|
+
token: preferencesToken,
|
|
163
|
+
unsubscribed: true,
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
// RFC 8058 one-click requests require no cookies and never redirect.
|
|
169
|
+
return new Response('OK', { status: 200, headers: unsubscribeHeaders })
|
|
130
170
|
}
|
|
131
171
|
|
|
132
|
-
return
|
|
172
|
+
return { GET, POST }
|
|
133
173
|
}
|
|
174
|
+
|
|
175
|
+
export const { GET, POST } = createUnsubscribeHandlers()
|