@growth-labs/mailer 0.4.11 → 0.5.1
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 +18 -2
- package/dist/options.d.ts +12 -6
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +4 -2
- package/dist/options.js.map +1 -1
- package/dist/queue/consumer.d.ts +12 -2
- package/dist/queue/consumer.d.ts.map +1 -1
- package/dist/queue/consumer.js +66 -0
- package/dist/queue/consumer.js.map +1 -1
- package/dist/routes/confirm.d.ts.map +1 -1
- package/dist/routes/confirm.js +6 -2
- package/dist/routes/confirm.js.map +1 -1
- package/dist/routes/subscribe.d.ts.map +1 -1
- package/dist/routes/subscribe.js +11 -3
- package/dist/routes/subscribe.js.map +1 -1
- package/dist/routes/unsubscribe.d.ts.map +1 -1
- package/dist/routes/unsubscribe.js +14 -3
- package/dist/routes/unsubscribe.js.map +1 -1
- package/dist/schema/sends.d.ts +38 -0
- package/dist/schema/sends.d.ts.map +1 -1
- package/dist/schema/sends.js +2 -0
- package/dist/schema/sends.js.map +1 -1
- package/dist/types.d.ts +22 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/broadcast.d.ts +12 -0
- package/dist/utils/broadcast.d.ts.map +1 -1
- package/dist/utils/broadcast.js +17 -3
- package/dist/utils/broadcast.js.map +1 -1
- package/dist/utils/delivery-claim.d.ts +37 -0
- package/dist/utils/delivery-claim.d.ts.map +1 -0
- package/dist/utils/delivery-claim.js +46 -0
- package/dist/utils/delivery-claim.js.map +1 -0
- package/dist/utils/runtime-secrets.d.ts +25 -0
- package/dist/utils/runtime-secrets.d.ts.map +1 -0
- package/dist/utils/runtime-secrets.js +96 -0
- package/dist/utils/runtime-secrets.js.map +1 -0
- package/dist/utils/send.d.ts.map +1 -1
- package/dist/utils/send.js +24 -6
- package/dist/utils/send.js.map +1 -1
- package/dist/utils/tokens.d.ts +3 -1
- package/dist/utils/tokens.d.ts.map +1 -1
- package/dist/utils/tokens.js +14 -5
- package/dist/utils/tokens.js.map +1 -1
- package/dist/utils/urls.d.ts +1 -1
- package/dist/utils/urls.d.ts.map +1 -1
- package/dist/utils/urls.js +9 -3
- package/dist/utils/urls.js.map +1 -1
- package/dist/vite-plugin.d.ts +1 -0
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +13 -1
- package/dist/vite-plugin.js.map +1 -1
- package/migrations/0003_add_delivery_claim_to_email_sends.sql +8 -0
- package/package.json +4 -4
- package/src/options.ts +4 -2
- package/src/queue/consumer.ts +116 -2
- package/src/routes/confirm.ts +9 -2
- package/src/routes/preferences.astro +12 -3
- package/src/routes/subscribe.ts +15 -3
- package/src/routes/unsubscribe.ts +16 -3
- package/src/schema/sends.ts +2 -0
- package/src/types.ts +23 -0
- package/src/utils/broadcast.ts +16 -3
- package/src/utils/delivery-claim.ts +66 -0
- package/src/utils/runtime-secrets.ts +144 -0
- package/src/utils/send.ts +27 -5
- package/src/utils/tokens.ts +16 -5
- package/src/utils/urls.ts +9 -6
- package/src/virtual.d.ts +4 -2
- package/src/vite-plugin.ts +18 -1
package/dist/utils/tokens.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/utils/tokens.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAA;AAEjC,SAAS,WAAW,CAAC,MAAgC;IACpD,MAAM,KAAK,GAAG,MAAM,YAAY,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,CAAA;IAC5E,OAAO,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,CAAC;SACxC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC;SACnB,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC;SACnB,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;AACrB,CAAC;AAED,SAAS,aAAa,CAAC,GAAW;IACjC,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAA;IACjG,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,CAAA;IAC3B,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAC3C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACxC,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;IAChC,CAAC;IACD,OAAO,KAAK,CAAA;AACb,CAAC;AAED,KAAK,UAAU,SAAS,CAAC,MAAc;IACtC,OAAO,MAAM,CAAC,MAAM,CAAC,SAAS,CAC7B,KAAK,EACL,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EACtB,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,EACjC,KAAK,EACL,CAAC,MAAM,EAAE,QAAQ,CAAC,CAClB,CAAA;AACF,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,MAAc,EAAE,IAAY;IACnD,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,CAAA;IACnC,MAAM,SAAS,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;IAC7E,OAAO,WAAW,CAAC,SAAS,CAAC,CAAA;AAC9B,CAAC;AAED,KAAK,UAAU,UAAU,CAAC,MAAc,EAAE,IAAY,EAAE,SAAiB;IACxE,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,CAAA;IACnC,MAAM,QAAQ,GAAG,aAAa,CAAC,SAAS,CAAC,CAAA;IACzC,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,EAAE,QAAQ,CAAC,MAAqB,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;AAC/F,CAAC;
|
|
1
|
+
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/utils/tokens.ts"],"names":[],"mappings":"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"}
|
package/dist/utils/urls.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedMailerOptions } from '../options.js';
|
|
2
2
|
export declare function buildSiteUrl(siteUrl: string, path: string, searchParams?: Record<string, string | boolean | undefined>): string;
|
|
3
|
-
export declare function buildSubscriberManageUrls(options: Pick<ResolvedMailerOptions, 'preferencesPath' | 'siteUrl' | '
|
|
3
|
+
export declare function buildSubscriberManageUrls(options: Pick<ResolvedMailerOptions, 'preferencesPath' | 'siteUrl' | 'unsubscribePath'>, subscriberId?: string, signingSecret?: string): Promise<{
|
|
4
4
|
preferencesUrl: string;
|
|
5
5
|
unsubscribeUrl: string;
|
|
6
6
|
}>;
|
package/dist/utils/urls.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"urls.d.ts","sourceRoot":"","sources":["../../src/utils/urls.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"urls.d.ts","sourceRoot":"","sources":["../../src/utils/urls.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAI1D,wBAAgB,YAAY,CAC3B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,GACzD,MAAM,CASR;AAED,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,IAAI,CAAC,qBAAqB,EAAE,iBAAiB,GAAG,SAAS,GAAG,iBAAiB,CAAC,EACvF,YAAY,CAAC,EAAE,MAAM,EACrB,aAAa,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAC,CA4B7D"}
|
package/dist/utils/urls.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { siteHost } from './runtime-secrets.js';
|
|
1
2
|
import { generateToken } from './tokens.js';
|
|
2
3
|
export function buildSiteUrl(siteUrl, path, searchParams) {
|
|
3
4
|
const url = new URL(path, siteUrl);
|
|
@@ -8,18 +9,23 @@ export function buildSiteUrl(siteUrl, path, searchParams) {
|
|
|
8
9
|
}
|
|
9
10
|
return url.toString();
|
|
10
11
|
}
|
|
11
|
-
export async function buildSubscriberManageUrls(options, subscriberId) {
|
|
12
|
+
export async function buildSubscriberManageUrls(options, subscriberId, signingSecret) {
|
|
12
13
|
if (!subscriberId) {
|
|
13
14
|
return { preferencesUrl: '', unsubscribeUrl: '' };
|
|
14
15
|
}
|
|
16
|
+
if (!signingSecret)
|
|
17
|
+
throw new Error('GL_MAILER_SIGNING_UNCONFIGURED');
|
|
18
|
+
const host = siteHost(options.siteUrl);
|
|
15
19
|
const [unsubscribeToken, preferencesToken] = await Promise.all([
|
|
16
|
-
generateToken(
|
|
20
|
+
generateToken(signingSecret, {
|
|
17
21
|
subscriberId,
|
|
18
22
|
action: 'unsubscribe',
|
|
23
|
+
host,
|
|
19
24
|
}),
|
|
20
|
-
generateToken(
|
|
25
|
+
generateToken(signingSecret, {
|
|
21
26
|
subscriberId,
|
|
22
27
|
action: 'preferences',
|
|
28
|
+
host,
|
|
23
29
|
}),
|
|
24
30
|
]);
|
|
25
31
|
return {
|
package/dist/utils/urls.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"urls.js","sourceRoot":"","sources":["../../src/utils/urls.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE3C,MAAM,UAAU,YAAY,CAC3B,OAAe,EACf,IAAY,EACZ,YAA2D;IAE3D,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IAElC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,EAAE,CAAC;QAC/D,IAAI,KAAK,KAAK,SAAS;YAAE,SAAQ;QACjC,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;IAC9E,CAAC;IAED,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAA;AACtB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC9C,
|
|
1
|
+
{"version":3,"file":"urls.js","sourceRoot":"","sources":["../../src/utils/urls.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE3C,MAAM,UAAU,YAAY,CAC3B,OAAe,EACf,IAAY,EACZ,YAA2D;IAE3D,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IAElC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,EAAE,CAAC;QAC/D,IAAI,KAAK,KAAK,SAAS;YAAE,SAAQ;QACjC,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;IAC9E,CAAC;IAED,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAA;AACtB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC9C,OAAuF,EACvF,YAAqB,EACrB,aAAsB;IAEtB,IAAI,CAAC,YAAY,EAAE,CAAC;QACnB,OAAO,EAAE,cAAc,EAAE,EAAE,EAAE,cAAc,EAAE,EAAE,EAAE,CAAA;IAClD,CAAC;IAED,IAAI,CAAC,aAAa;QAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAA;IACrE,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;IACtC,MAAM,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAC9D,aAAa,CAAC,aAAa,EAAE;YAC5B,YAAY;YACZ,MAAM,EAAE,aAAa;YACrB,IAAI;SACJ,CAAC;QACF,aAAa,CAAC,aAAa,EAAE;YAC5B,YAAY;YACZ,MAAM,EAAE,aAAa;YACrB,IAAI;SACJ,CAAC;KACF,CAAC,CAAA;IAEF,OAAO;QACN,cAAc,EAAE,YAAY,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,eAAe,EAAE;YACtE,KAAK,EAAE,gBAAgB;SACvB,CAAC;QACF,cAAc,EAAE,YAAY,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,eAAe,EAAE;YACtE,KAAK,EAAE,gBAAgB;SACvB,CAAC;KACF,CAAA;AACF,CAAC"}
|
package/dist/vite-plugin.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
2
|
import type { ResolvedMailerOptions } from './options.js';
|
|
3
|
+
export declare function renderMailerConfigModule(config: ResolvedMailerOptions, command: 'build' | 'serve'): string;
|
|
3
4
|
export declare function growthLabsMailerPlugin(config: ResolvedMailerOptions): Plugin;
|
|
4
5
|
//# sourceMappingURL=vite-plugin.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vite-plugin.d.ts","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAClC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAKzD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"vite-plugin.d.ts","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAClC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAKzD,wBAAgB,wBAAwB,CACvC,MAAM,EAAE,qBAAqB,EAC7B,OAAO,EAAE,OAAO,GAAG,OAAO,GACxB,MAAM,CAOR;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAmB5E"}
|
package/dist/vite-plugin.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
const VIRTUAL_MODULE_ID = 'virtual:growth-labs/mailer/config';
|
|
2
2
|
const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
|
|
3
|
+
export function renderMailerConfigModule(config, command) {
|
|
4
|
+
const serializableConfig = { ...config };
|
|
5
|
+
if (command !== 'serve') {
|
|
6
|
+
delete serializableConfig.turnstileSecretKey;
|
|
7
|
+
delete serializableConfig.signingSecret;
|
|
8
|
+
}
|
|
9
|
+
return `export const config = ${JSON.stringify(serializableConfig)};`;
|
|
10
|
+
}
|
|
3
11
|
export function growthLabsMailerPlugin(config) {
|
|
12
|
+
let command = 'build';
|
|
4
13
|
return {
|
|
5
14
|
name: 'growth-labs-mailer-config',
|
|
15
|
+
configResolved(resolvedConfig) {
|
|
16
|
+
command = resolvedConfig.command;
|
|
17
|
+
},
|
|
6
18
|
resolveId(id) {
|
|
7
19
|
if (id === VIRTUAL_MODULE_ID) {
|
|
8
20
|
return RESOLVED_VIRTUAL_MODULE_ID;
|
|
@@ -10,7 +22,7 @@ export function growthLabsMailerPlugin(config) {
|
|
|
10
22
|
},
|
|
11
23
|
load(id) {
|
|
12
24
|
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
13
|
-
return
|
|
25
|
+
return renderMailerConfigModule(config, command);
|
|
14
26
|
}
|
|
15
27
|
},
|
|
16
28
|
};
|
package/dist/vite-plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vite-plugin.js","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"AAGA,MAAM,iBAAiB,GAAG,mCAAmC,CAAA;AAC7D,MAAM,0BAA0B,GAAG,KAAK,iBAAiB,EAAE,CAAA;AAE3D,MAAM,UAAU,sBAAsB,CAAC,MAA6B;IACnE,OAAO;QACN,IAAI,EAAE,2BAA2B;QACjC,SAAS,CAAC,EAAE;YACX,IAAI,EAAE,KAAK,iBAAiB,EAAE,CAAC;gBAC9B,OAAO,0BAA0B,CAAA;YAClC,CAAC;QACF,CAAC;QACD,IAAI,CAAC,EAAE;YACN,IAAI,EAAE,KAAK,0BAA0B,EAAE,CAAC;gBACvC,OAAO,
|
|
1
|
+
{"version":3,"file":"vite-plugin.js","sourceRoot":"","sources":["../src/vite-plugin.ts"],"names":[],"mappings":"AAGA,MAAM,iBAAiB,GAAG,mCAAmC,CAAA;AAC7D,MAAM,0BAA0B,GAAG,KAAK,iBAAiB,EAAE,CAAA;AAE3D,MAAM,UAAU,wBAAwB,CACvC,MAA6B,EAC7B,OAA0B;IAE1B,MAAM,kBAAkB,GAAG,EAAE,GAAG,MAAM,EAAE,CAAA;IACxC,IAAI,OAAO,KAAK,OAAO,EAAE,CAAC;QACzB,OAAO,kBAAkB,CAAC,kBAAkB,CAAA;QAC5C,OAAO,kBAAkB,CAAC,aAAa,CAAA;IACxC,CAAC;IACD,OAAO,yBAAyB,IAAI,CAAC,SAAS,CAAC,kBAAkB,CAAC,GAAG,CAAA;AACtE,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,MAA6B;IACnE,IAAI,OAAO,GAAsB,OAAO,CAAA;IAExC,OAAO;QACN,IAAI,EAAE,2BAA2B;QACjC,cAAc,CAAC,cAAc;YAC5B,OAAO,GAAG,cAAc,CAAC,OAAO,CAAA;QACjC,CAAC;QACD,SAAS,CAAC,EAAE;YACX,IAAI,EAAE,KAAK,iBAAiB,EAAE,CAAC;gBAC9B,OAAO,0BAA0B,CAAA;YAClC,CAAC;QACF,CAAC;QACD,IAAI,CAAC,EAAE;YACN,IAAI,EAAE,KAAK,0BAA0B,EAAE,CAAC;gBACvC,OAAO,wBAAwB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;YACjD,CAAC;QACF,CAAC;KACD,CAAA;AACF,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- WM-01: send-time suppression recheck + a stable per-recipient delivery
|
|
2
|
+
-- claim. delivery_token is minted once at enqueue time and carried in the
|
|
3
|
+
-- queue message; the consumer's claim update matches on it so a message
|
|
4
|
+
-- superseded by a fresh resend cycle (or replayed from before a restore)
|
|
5
|
+
-- can never be actioned against the current row. claimed_at marks the
|
|
6
|
+
-- in-flight window between claiming the row and reconciling its outcome.
|
|
7
|
+
ALTER TABLE gl_email_sends ADD COLUMN delivery_token TEXT;
|
|
8
|
+
ALTER TABLE gl_email_sends ADD COLUMN claimed_at TEXT;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/mailer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"registry": "https://registry.npmjs.org/"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
|
-
"astro": "^6.1.10"
|
|
74
|
+
"astro": "^6.1.10 || ^7.0.0"
|
|
75
75
|
},
|
|
76
76
|
"peerDependenciesMeta": {
|
|
77
77
|
"@growth-labs/analytics": {
|
|
@@ -85,10 +85,10 @@
|
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@cloudflare/workers-types": "^4.20260402.1",
|
|
88
|
-
"astro": "
|
|
88
|
+
"astro": "7.3.1",
|
|
89
89
|
"drizzle-kit": "^0.30.0",
|
|
90
90
|
"typescript": "^5.7.0",
|
|
91
|
-
"vite": "
|
|
91
|
+
"vite": "8.0.16",
|
|
92
92
|
"vitest": "^4.1.8"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
package/src/options.ts
CHANGED
|
@@ -25,14 +25,16 @@ export const mailerOptionsSchema = z.object({
|
|
|
25
25
|
|
|
26
26
|
// ─── Turnstile (subscribe form) ───
|
|
27
27
|
turnstileSiteKey: z.string(),
|
|
28
|
-
|
|
28
|
+
turnstileSecretBinding: z.string().min(1).default('TURNSTILE_SECRET_KEY'),
|
|
29
|
+
turnstileSecretKey: z.string().optional(),
|
|
29
30
|
|
|
30
31
|
// ─── Subscriber lifecycle ───
|
|
31
32
|
doubleOptIn: z.boolean().default(true),
|
|
32
33
|
topics: z.array(z.string()).optional(),
|
|
33
34
|
|
|
34
35
|
// ─── Token signing ───
|
|
35
|
-
|
|
36
|
+
signingSecretBinding: z.string().min(1).default('MAILER_SIGNING_SECRET'),
|
|
37
|
+
signingSecret: z.string().optional(),
|
|
36
38
|
|
|
37
39
|
// ─── Routes ───
|
|
38
40
|
subscribePath: z.string().default('/api/newsletter/subscribe'),
|
package/src/queue/consumer.ts
CHANGED
|
@@ -1,18 +1,82 @@
|
|
|
1
1
|
import { drizzle } from 'drizzle-orm/d1'
|
|
2
2
|
import { probeMailerSendsSchema } from '../_internal/schema-probe.js'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
EmailQueueMessage,
|
|
5
|
+
QueueRecipient,
|
|
6
|
+
SiteConfigLookup,
|
|
7
|
+
SiteMailerConfig,
|
|
8
|
+
SubscriberStatusLookup,
|
|
9
|
+
} from '../types.js'
|
|
4
10
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
5
11
|
import { updateSendStatus } from '../utils/bounce.js'
|
|
12
|
+
import { SUPPRESSED_STATUSES } from '../utils/broadcast.js'
|
|
13
|
+
import { claimDelivery } from '../utils/delivery-claim.js'
|
|
6
14
|
import type { CloudflareEmailSender } from '../utils/providers.js'
|
|
7
15
|
import { CloudflareEmailProvider, sleep } from '../utils/providers.js'
|
|
16
|
+
import { getSubscriberById } from '../utils/subscribers.js'
|
|
8
17
|
|
|
9
|
-
export type { SiteConfigLookup, SiteMailerConfig } from '../types.js'
|
|
18
|
+
export type { SiteConfigLookup, SiteMailerConfig, SubscriberStatusLookup } from '../types.js'
|
|
19
|
+
|
|
20
|
+
type DrizzleDB = ReturnType<typeof drizzle>
|
|
10
21
|
|
|
11
22
|
export interface MailerConsumerOptions {
|
|
12
23
|
d1Binding: string
|
|
13
24
|
senderBinding: string
|
|
14
25
|
analyticsBinding?: string
|
|
15
26
|
siteConfigLookup: SiteConfigLookup
|
|
27
|
+
/**
|
|
28
|
+
* Recheck a recipient's current suppression status at send admission
|
|
29
|
+
* (WM-01). Defaults to querying `gl_subscribers` on `env[d1Binding]` —
|
|
30
|
+
* correct for single-tenant sites. A realm consumer whose `d1Binding` D1
|
|
31
|
+
* holds `gl_email_sends` only (subscribers live in each site's own D1)
|
|
32
|
+
* must supply this for the recheck to be effective; unset in that
|
|
33
|
+
* topology, the consumer logs GL_MAILER_SUPPRESSION_CHECK_UNAVAILABLE
|
|
34
|
+
* once per instance and falls back to enqueue-time suppression only.
|
|
35
|
+
*/
|
|
36
|
+
subscriberStatusLookup?: SubscriberStatusLookup
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let subscriberLookupUnavailableLogged = false
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when the recipient's subscriber row currently carries a suppressed
|
|
43
|
+
* status — rechecked at send admission, not trusted from enqueue time
|
|
44
|
+
* (WM-01). Falls back to a direct `gl_subscribers` query when no
|
|
45
|
+
* `subscriberStatusLookup` is configured; a query failure (table absent —
|
|
46
|
+
* the realm-consumer topology) logs once and fails open rather than
|
|
47
|
+
* silently blocking every send in a deployment this check can't reach.
|
|
48
|
+
*/
|
|
49
|
+
async function isCurrentlySuppressed(
|
|
50
|
+
db: DrizzleDB,
|
|
51
|
+
recipient: QueueRecipient,
|
|
52
|
+
siteId: string,
|
|
53
|
+
env: Record<string, unknown>,
|
|
54
|
+
lookup: SubscriberStatusLookup | undefined,
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
if (lookup) {
|
|
57
|
+
const status = await lookup(
|
|
58
|
+
{ subscriberId: recipient.subscriberId, siteId, email: recipient.email },
|
|
59
|
+
env,
|
|
60
|
+
)
|
|
61
|
+
return status !== null && SUPPRESSED_STATUSES.has(status)
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const subscriber = await getSubscriberById(db, recipient.subscriberId)
|
|
65
|
+
return subscriber !== null && SUPPRESSED_STATUSES.has(subscriber.status)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (!subscriberLookupUnavailableLogged) {
|
|
68
|
+
subscriberLookupUnavailableLogged = true
|
|
69
|
+
console.error(
|
|
70
|
+
'[mailer] GL_MAILER_SUPPRESSION_CHECK_UNAVAILABLE: cannot recheck suppression at send ' +
|
|
71
|
+
"time — gl_subscribers is not queryable on env.<d1Binding> (a realm consumer's " +
|
|
72
|
+
'd1Binding D1 holds gl_email_sends only; subscribers live in each site D1). Wire ' +
|
|
73
|
+
'MailerConsumerOptions.subscriberStatusLookup to close this gap. Sending without a ' +
|
|
74
|
+
'send-time recheck for this Worker instance. ' +
|
|
75
|
+
(err instanceof Error ? err.message : String(err)),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
16
80
|
}
|
|
17
81
|
|
|
18
82
|
/** Effective options for a single message, resolved from `siteConfigLookup`. */
|
|
@@ -106,6 +170,56 @@ export async function handleEmailQueue(
|
|
|
106
170
|
const effectiveReplyTo = effective.replyTo ?? replyTo
|
|
107
171
|
|
|
108
172
|
for (const recipient of recipients) {
|
|
173
|
+
const claimedAt = new Date().toISOString()
|
|
174
|
+
const claim = await claimDelivery(
|
|
175
|
+
db,
|
|
176
|
+
recipient.trackingId,
|
|
177
|
+
recipient.deliveryToken,
|
|
178
|
+
claimedAt,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if (claim === 'missing' || claim === 'invalid-authorization') {
|
|
182
|
+
console.error(
|
|
183
|
+
`[mailer] Refusing to send trackingId=${recipient.trackingId}: ${claim} — stale, ` +
|
|
184
|
+
'superseded, or a restore-era replay. Not sending, not touching the record.',
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (claim === 'already-terminal') {
|
|
190
|
+
// Duplicate enqueue or redelivery of an already fully-processed
|
|
191
|
+
// send — a no-op, not a resend.
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (claim === 'reconcile-in-flight') {
|
|
196
|
+
// A prior attempt claimed this row and never reached ack (crash or
|
|
197
|
+
// timeout before message.ack()). We cannot know whether the
|
|
198
|
+
// provider call actually completed, but calling it again risks a
|
|
199
|
+
// duplicate real send — reconcile as sent instead of resending.
|
|
200
|
+
await updateSendStatus(db, recipient.trackingId, 'sent', {
|
|
201
|
+
sentAt: new Date().toISOString(),
|
|
202
|
+
})
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// claim === 'claimed' — this invocation owns the send. Recheck
|
|
207
|
+
// suppression now: the subscriber's status may have changed since
|
|
208
|
+
// this message was enqueued.
|
|
209
|
+
if (type !== 'transactional') {
|
|
210
|
+
const suppressed = await isCurrentlySuppressed(
|
|
211
|
+
db,
|
|
212
|
+
recipient,
|
|
213
|
+
message.body.siteId,
|
|
214
|
+
env,
|
|
215
|
+
options.subscriberStatusLookup,
|
|
216
|
+
)
|
|
217
|
+
if (suppressed) {
|
|
218
|
+
await updateSendStatus(db, recipient.trackingId, 'suppressed', {})
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
109
223
|
let html = htmlTemplate
|
|
110
224
|
if (type !== 'transactional') {
|
|
111
225
|
const siteUrl = effective.siteUrl ?? ''
|
package/src/routes/confirm.ts
CHANGED
|
@@ -4,6 +4,11 @@ import type { APIRoute } from 'astro'
|
|
|
4
4
|
import { drizzle } from 'drizzle-orm/d1'
|
|
5
5
|
import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
|
|
6
6
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
7
|
+
import {
|
|
8
|
+
resolveSigningSecret,
|
|
9
|
+
runtimeSecretErrorResponse,
|
|
10
|
+
siteHost,
|
|
11
|
+
} from '../utils/runtime-secrets.js'
|
|
7
12
|
import { sendTransactional } from '../utils/send.js'
|
|
8
13
|
import { confirmSubscriber, getSubscriberById } from '../utils/subscribers.js'
|
|
9
14
|
import { verifyToken } from '../utils/tokens.js'
|
|
@@ -14,15 +19,17 @@ export const GET: APIRoute = async (context) => {
|
|
|
14
19
|
if (!token) {
|
|
15
20
|
return Response.json({ error: 'Missing token' }, { status: 400 })
|
|
16
21
|
}
|
|
22
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
23
|
+
const signingSecret = await resolveSigningSecret(bindingsEnv, config, url.toString())
|
|
24
|
+
if (!signingSecret.ok) return runtimeSecretErrorResponse(signingSecret)
|
|
17
25
|
|
|
18
26
|
// Verify token
|
|
19
|
-
const payload = await verifyToken(
|
|
27
|
+
const payload = await verifyToken(signingSecret.secret, token, siteHost(config.siteUrl))
|
|
20
28
|
if (!payload || payload.action !== 'confirm') {
|
|
21
29
|
return Response.json({ error: 'Invalid or expired token' }, { status: 400 })
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
// Resolve bindings
|
|
25
|
-
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
26
33
|
const d1 = bindingsEnv[config.d1Binding] as D1Database
|
|
27
34
|
|
|
28
35
|
// Schema probe — return 503 with GL_MAILER_SCHEMA_MISSING on miss.
|
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
unsubscribeSubscriber,
|
|
9
9
|
updatePreferences,
|
|
10
10
|
} from "../utils/subscribers.js";
|
|
11
|
+
import {
|
|
12
|
+
resolveSigningSecret,
|
|
13
|
+
runtimeSecretErrorResponse,
|
|
14
|
+
siteHost,
|
|
15
|
+
} from "../utils/runtime-secrets.js";
|
|
11
16
|
import { generateToken, verifyToken } from "../utils/tokens.js";
|
|
12
17
|
import { buildSiteUrl } from "../utils/urls.js";
|
|
13
18
|
|
|
@@ -27,15 +32,20 @@ if (!token) {
|
|
|
27
32
|
return new Response("Missing token", { status: 400 });
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
const
|
|
35
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>;
|
|
36
|
+
const signingSecret = await resolveSigningSecret(bindingsEnv, config, Astro.request.url);
|
|
37
|
+
if (!signingSecret.ok) return runtimeSecretErrorResponse(signingSecret);
|
|
38
|
+
|
|
39
|
+
const payload = await verifyToken(signingSecret.secret, token, siteHost(config.siteUrl));
|
|
31
40
|
if (!payload || !["preferences", "unsubscribe"].includes(payload.action)) {
|
|
32
41
|
return new Response("Invalid or expired token", { status: 400 });
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
if (Astro.request.method === "GET" && payload.action === "unsubscribe") {
|
|
36
|
-
const preferencesToken = await generateToken(
|
|
45
|
+
const preferencesToken = await generateToken(signingSecret.secret, {
|
|
37
46
|
subscriberId: payload.subscriberId,
|
|
38
47
|
action: "preferences",
|
|
48
|
+
host: siteHost(config.siteUrl),
|
|
39
49
|
});
|
|
40
50
|
|
|
41
51
|
return Astro.redirect(
|
|
@@ -51,7 +61,6 @@ if (Astro.request.method === "POST" && payload.action !== "preferences") {
|
|
|
51
61
|
return new Response("Invalid or expired token", { status: 400 });
|
|
52
62
|
}
|
|
53
63
|
|
|
54
|
-
const bindingsEnv = cloudflareEnv as Record<string, unknown>;
|
|
55
64
|
const d1 = bindingsEnv[config.d1Binding] as D1Database;
|
|
56
65
|
if (!d1) {
|
|
57
66
|
return new Response("D1 binding not available", { status: 500 });
|
package/src/routes/subscribe.ts
CHANGED
|
@@ -4,6 +4,12 @@ import type { APIRoute } from 'astro'
|
|
|
4
4
|
import { drizzle } from 'drizzle-orm/d1'
|
|
5
5
|
import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
|
|
6
6
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
7
|
+
import {
|
|
8
|
+
resolveSigningSecret,
|
|
9
|
+
resolveTurnstileSecret,
|
|
10
|
+
runtimeSecretErrorResponse,
|
|
11
|
+
siteHost,
|
|
12
|
+
} from '../utils/runtime-secrets.js'
|
|
7
13
|
import { sendTransactional } from '../utils/send.js'
|
|
8
14
|
import { createSubscriber } from '../utils/subscribers.js'
|
|
9
15
|
import { generateToken } from '../utils/tokens.js'
|
|
@@ -29,6 +35,12 @@ export const POST: APIRoute = async (context) => {
|
|
|
29
35
|
return Response.json({ error: 'Invalid email format' }, { status: 400 })
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
39
|
+
const turnstileSecret = await resolveTurnstileSecret(bindingsEnv, config, request.url)
|
|
40
|
+
if (!turnstileSecret.ok) return runtimeSecretErrorResponse(turnstileSecret)
|
|
41
|
+
const signingSecret = await resolveSigningSecret(bindingsEnv, config, request.url)
|
|
42
|
+
if (!signingSecret.ok) return runtimeSecretErrorResponse(signingSecret)
|
|
43
|
+
|
|
32
44
|
// 4. Validate Turnstile token
|
|
33
45
|
const turnstileResponse = await fetch(
|
|
34
46
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
@@ -36,7 +48,7 @@ export const POST: APIRoute = async (context) => {
|
|
|
36
48
|
method: 'POST',
|
|
37
49
|
headers: { 'Content-Type': 'application/json' },
|
|
38
50
|
body: JSON.stringify({
|
|
39
|
-
secret:
|
|
51
|
+
secret: turnstileSecret.secret,
|
|
40
52
|
response: body.turnstileToken,
|
|
41
53
|
remoteip: request.headers.get('cf-connecting-ip') ?? undefined,
|
|
42
54
|
}),
|
|
@@ -50,7 +62,6 @@ export const POST: APIRoute = async (context) => {
|
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
// 5. Resolve bindings
|
|
53
|
-
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
54
65
|
const d1 = bindingsEnv[config.d1Binding] as D1Database
|
|
55
66
|
|
|
56
67
|
// 5a. Schema probe — runs once per Worker instance. On miss, return 503
|
|
@@ -72,9 +83,10 @@ export const POST: APIRoute = async (context) => {
|
|
|
72
83
|
|
|
73
84
|
// 7. Send confirmation or welcome email
|
|
74
85
|
if (config.doubleOptIn && (isNew || subscriber.status === 'pending')) {
|
|
75
|
-
const confirmToken = await generateToken(
|
|
86
|
+
const confirmToken = await generateToken(signingSecret.secret, {
|
|
76
87
|
subscriberId: subscriber.id,
|
|
77
88
|
action: 'confirm',
|
|
89
|
+
host: siteHost(config.siteUrl),
|
|
78
90
|
})
|
|
79
91
|
const confirmUrl = `${config.siteUrl}${config.confirmPath}?token=${confirmToken}`
|
|
80
92
|
|
|
@@ -4,6 +4,11 @@ import type { APIRoute } from 'astro'
|
|
|
4
4
|
import { drizzle } from 'drizzle-orm/d1'
|
|
5
5
|
import { probeMailerSchema, schemaMissingResponse } from '../_internal/schema-probe.js'
|
|
6
6
|
import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
|
|
7
|
+
import {
|
|
8
|
+
resolveSigningSecret,
|
|
9
|
+
runtimeSecretErrorResponse,
|
|
10
|
+
siteHost,
|
|
11
|
+
} from '../utils/runtime-secrets.js'
|
|
7
12
|
import { sendTransactional } from '../utils/send.js'
|
|
8
13
|
import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js'
|
|
9
14
|
import { generateToken, verifyToken } from '../utils/tokens.js'
|
|
@@ -14,14 +19,18 @@ async function processUnsubscribe(
|
|
|
14
19
|
context?: Parameters<APIRoute>[0],
|
|
15
20
|
request?: Request,
|
|
16
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
|
+
|
|
17
27
|
// Verify token
|
|
18
|
-
const payload = await verifyToken(
|
|
28
|
+
const payload = await verifyToken(signingSecret.secret, token, siteHost(config.siteUrl))
|
|
19
29
|
if (!payload || payload.action !== 'unsubscribe') {
|
|
20
30
|
return { error: 'Invalid or expired token', status: 400 }
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
// Resolve bindings
|
|
24
|
-
const bindingsEnv = cloudflareEnv as Record<string, unknown>
|
|
25
34
|
const d1 = bindingsEnv[config.d1Binding] as D1Database
|
|
26
35
|
|
|
27
36
|
// Schema probe — short-circuit with a Response on miss; both GET and POST
|
|
@@ -66,6 +75,7 @@ async function processUnsubscribe(
|
|
|
66
75
|
return {
|
|
67
76
|
success: true,
|
|
68
77
|
subscriberId: payload.subscriberId,
|
|
78
|
+
signingSecret: signingSecret.secret,
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -78,15 +88,17 @@ export const GET: APIRoute = async (context) => {
|
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
const result = await processUnsubscribe(token, context, request)
|
|
91
|
+
if (result.secretFailure) return runtimeSecretErrorResponse(result.secretFailure)
|
|
81
92
|
if ('schemaMissing' in result) return schemaMissingResponse()
|
|
82
93
|
if ('error' in result) {
|
|
83
94
|
return Response.json({ error: result.error }, { status: result.status })
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
// Generate preferences token for the redirect
|
|
87
|
-
const preferencesToken = await generateToken(
|
|
98
|
+
const preferencesToken = await generateToken(result.signingSecret, {
|
|
88
99
|
subscriberId: result.subscriberId,
|
|
89
100
|
action: 'preferences',
|
|
101
|
+
host: siteHost(config.siteUrl),
|
|
90
102
|
})
|
|
91
103
|
const preferencesUrl = buildSiteUrl(config.siteUrl, config.preferencesPath, {
|
|
92
104
|
token: preferencesToken,
|
|
@@ -111,6 +123,7 @@ export const POST: APIRoute = async (context) => {
|
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
const result = await processUnsubscribe(token, context, request)
|
|
126
|
+
if (result.secretFailure) return runtimeSecretErrorResponse(result.secretFailure)
|
|
114
127
|
if ('schemaMissing' in result) return schemaMissingResponse()
|
|
115
128
|
if ('error' in result) {
|
|
116
129
|
return new Response(result.error, { status: result.status })
|
package/src/schema/sends.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const emailSends = sqliteTable(
|
|
|
19
19
|
bounceType: text('bounce_type'),
|
|
20
20
|
complainedAt: text('complained_at'),
|
|
21
21
|
trackingId: text('tracking_id').notNull().unique(),
|
|
22
|
+
deliveryToken: text('delivery_token'),
|
|
23
|
+
claimedAt: text('claimed_at'),
|
|
22
24
|
createdAt: text('created_at').notNull(),
|
|
23
25
|
},
|
|
24
26
|
(table) => [
|
package/src/types.ts
CHANGED
|
@@ -82,12 +82,33 @@ export type SiteConfigLookup = (
|
|
|
82
82
|
env: Record<string, unknown>,
|
|
83
83
|
) => Promise<SiteMailerConfig | null>
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Recheck a recipient's current suppression status at send admission
|
|
87
|
+
* (WM-01). The default consumer implementation queries `gl_subscribers` on
|
|
88
|
+
* `env[d1Binding]`, which is correct for single-tenant sites. A realm
|
|
89
|
+
* consumer whose `d1Binding` D1 holds `gl_email_sends` only — subscribers
|
|
90
|
+
* live in each site's own D1 — must supply this to make the recheck
|
|
91
|
+
* effective for real; see MailerConsumerOptions.subscriberStatusLookup.
|
|
92
|
+
*/
|
|
93
|
+
export type SubscriberStatusLookup = (
|
|
94
|
+
params: { subscriberId: string; siteId: string; email: string },
|
|
95
|
+
env: Record<string, unknown>,
|
|
96
|
+
) => Promise<SubscriberStatus | null>
|
|
97
|
+
|
|
85
98
|
export interface QueueRecipient {
|
|
86
99
|
email: string
|
|
87
100
|
subscriberId: string
|
|
88
101
|
trackingId: string
|
|
89
102
|
unsubscribeToken: string
|
|
90
103
|
preferencesToken: string
|
|
104
|
+
/**
|
|
105
|
+
* Minted once at enqueue time and carried through to the consumer (WM-01).
|
|
106
|
+
* The consumer's delivery claim matches this against the current
|
|
107
|
+
* `gl_email_sends` row before sending, so a message superseded by a later
|
|
108
|
+
* resend cycle — or replayed from before a restore — can't act on a row
|
|
109
|
+
* it no longer authorizes.
|
|
110
|
+
*/
|
|
111
|
+
deliveryToken: string
|
|
91
112
|
}
|
|
92
113
|
|
|
93
114
|
// ─── Email send record ───
|
|
@@ -113,12 +134,14 @@ export interface EmailSend {
|
|
|
113
134
|
|
|
114
135
|
export type EmailSendStatus =
|
|
115
136
|
| 'queued'
|
|
137
|
+
| 'sending'
|
|
116
138
|
| 'sent'
|
|
117
139
|
| 'delivered'
|
|
118
140
|
| 'opened'
|
|
119
141
|
| 'clicked'
|
|
120
142
|
| 'bounced'
|
|
121
143
|
| 'complained'
|
|
144
|
+
| 'suppressed'
|
|
122
145
|
|
|
123
146
|
// ─── Email provider interface ───
|
|
124
147
|
|
package/src/utils/broadcast.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { emailSends } from '../schema/sends.js'
|
|
|
6
6
|
import { subscribers } from '../schema/subscribers.js'
|
|
7
7
|
import type { EmailQueueMessage, QueueRecipient, SubscriberStatus } from '../types.js'
|
|
8
8
|
import { sleep } from './providers.js'
|
|
9
|
+
import { resolveSigningSecret, siteHost } from './runtime-secrets.js'
|
|
9
10
|
import type { MailerEnv } from './send.js'
|
|
10
11
|
import { generateToken } from './tokens.js'
|
|
11
12
|
import { injectTrackingPixel, rewriteLinksForTracking } from './tracking.js'
|
|
@@ -18,8 +19,11 @@ type DrizzleDB = ReturnType<typeof drizzle>
|
|
|
18
19
|
* the ONLY sendable status; everything else (including `pending`, i.e. an
|
|
19
20
|
* unconfirmed double-opt-in subscriber) is suppressed so a one-off blast can
|
|
20
21
|
* never re-permission or re-mail a suppressed address.
|
|
22
|
+
*
|
|
23
|
+
* Exported so the queue consumer can recheck against the same set at send
|
|
24
|
+
* admission (WM-01) — the same statuses that block enqueue must block send.
|
|
21
25
|
*/
|
|
22
|
-
const SUPPRESSED_STATUSES: ReadonlySet<SubscriberStatus> = new Set<SubscriberStatus>([
|
|
26
|
+
export const SUPPRESSED_STATUSES: ReadonlySet<SubscriberStatus> = new Set<SubscriberStatus>([
|
|
23
27
|
'unsubscribed',
|
|
24
28
|
'bounced',
|
|
25
29
|
'complained',
|
|
@@ -330,6 +334,10 @@ export async function sendBroadcast(
|
|
|
330
334
|
dryRun: false,
|
|
331
335
|
}
|
|
332
336
|
}
|
|
337
|
+
const signingSecretResult = await resolveSigningSecret(env, options, options.siteUrl)
|
|
338
|
+
if (!signingSecretResult.ok) throw new Error(signingSecretResult.code)
|
|
339
|
+
const signingSecret = signingSecretResult.secret
|
|
340
|
+
const host = siteHost(options.siteUrl)
|
|
333
341
|
|
|
334
342
|
const html = prepareBroadcastHtml(params.html, options)
|
|
335
343
|
const now = new Date().toISOString()
|
|
@@ -338,14 +346,17 @@ export async function sendBroadcast(
|
|
|
338
346
|
const queueRecipients: QueueRecipient[] = []
|
|
339
347
|
for (const recipient of fresh) {
|
|
340
348
|
const trackingId = ulid()
|
|
349
|
+
const deliveryToken = ulid()
|
|
341
350
|
const [unsubscribeToken, preferencesToken] = await Promise.all([
|
|
342
|
-
generateToken(
|
|
351
|
+
generateToken(signingSecret, {
|
|
343
352
|
subscriberId: recipient.subscriberId,
|
|
344
353
|
action: 'unsubscribe',
|
|
354
|
+
host,
|
|
345
355
|
}),
|
|
346
|
-
generateToken(
|
|
356
|
+
generateToken(signingSecret, {
|
|
347
357
|
subscriberId: recipient.subscriberId,
|
|
348
358
|
action: 'preferences',
|
|
359
|
+
host,
|
|
349
360
|
}),
|
|
350
361
|
])
|
|
351
362
|
|
|
@@ -359,6 +370,7 @@ export async function sendBroadcast(
|
|
|
359
370
|
type: 'broadcast',
|
|
360
371
|
status: 'queued',
|
|
361
372
|
trackingId,
|
|
373
|
+
deliveryToken,
|
|
362
374
|
createdAt: now,
|
|
363
375
|
})
|
|
364
376
|
|
|
@@ -368,6 +380,7 @@ export async function sendBroadcast(
|
|
|
368
380
|
trackingId,
|
|
369
381
|
unsubscribeToken,
|
|
370
382
|
preferencesToken,
|
|
383
|
+
deliveryToken,
|
|
371
384
|
})
|
|
372
385
|
}
|
|
373
386
|
|