@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.
Files changed (69) hide show
  1. package/README.md +18 -2
  2. package/dist/options.d.ts +12 -6
  3. package/dist/options.d.ts.map +1 -1
  4. package/dist/options.js +4 -2
  5. package/dist/options.js.map +1 -1
  6. package/dist/queue/consumer.d.ts +12 -2
  7. package/dist/queue/consumer.d.ts.map +1 -1
  8. package/dist/queue/consumer.js +66 -0
  9. package/dist/queue/consumer.js.map +1 -1
  10. package/dist/routes/confirm.d.ts.map +1 -1
  11. package/dist/routes/confirm.js +6 -2
  12. package/dist/routes/confirm.js.map +1 -1
  13. package/dist/routes/subscribe.d.ts.map +1 -1
  14. package/dist/routes/subscribe.js +11 -3
  15. package/dist/routes/subscribe.js.map +1 -1
  16. package/dist/routes/unsubscribe.d.ts.map +1 -1
  17. package/dist/routes/unsubscribe.js +14 -3
  18. package/dist/routes/unsubscribe.js.map +1 -1
  19. package/dist/schema/sends.d.ts +38 -0
  20. package/dist/schema/sends.d.ts.map +1 -1
  21. package/dist/schema/sends.js +2 -0
  22. package/dist/schema/sends.js.map +1 -1
  23. package/dist/types.d.ts +22 -1
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/utils/broadcast.d.ts +12 -0
  26. package/dist/utils/broadcast.d.ts.map +1 -1
  27. package/dist/utils/broadcast.js +17 -3
  28. package/dist/utils/broadcast.js.map +1 -1
  29. package/dist/utils/delivery-claim.d.ts +37 -0
  30. package/dist/utils/delivery-claim.d.ts.map +1 -0
  31. package/dist/utils/delivery-claim.js +46 -0
  32. package/dist/utils/delivery-claim.js.map +1 -0
  33. package/dist/utils/runtime-secrets.d.ts +25 -0
  34. package/dist/utils/runtime-secrets.d.ts.map +1 -0
  35. package/dist/utils/runtime-secrets.js +96 -0
  36. package/dist/utils/runtime-secrets.js.map +1 -0
  37. package/dist/utils/send.d.ts.map +1 -1
  38. package/dist/utils/send.js +24 -6
  39. package/dist/utils/send.js.map +1 -1
  40. package/dist/utils/tokens.d.ts +3 -1
  41. package/dist/utils/tokens.d.ts.map +1 -1
  42. package/dist/utils/tokens.js +14 -5
  43. package/dist/utils/tokens.js.map +1 -1
  44. package/dist/utils/urls.d.ts +1 -1
  45. package/dist/utils/urls.d.ts.map +1 -1
  46. package/dist/utils/urls.js +9 -3
  47. package/dist/utils/urls.js.map +1 -1
  48. package/dist/vite-plugin.d.ts +1 -0
  49. package/dist/vite-plugin.d.ts.map +1 -1
  50. package/dist/vite-plugin.js +13 -1
  51. package/dist/vite-plugin.js.map +1 -1
  52. package/migrations/0003_add_delivery_claim_to_email_sends.sql +8 -0
  53. package/package.json +4 -4
  54. package/src/options.ts +4 -2
  55. package/src/queue/consumer.ts +116 -2
  56. package/src/routes/confirm.ts +9 -2
  57. package/src/routes/preferences.astro +12 -3
  58. package/src/routes/subscribe.ts +15 -3
  59. package/src/routes/unsubscribe.ts +16 -3
  60. package/src/schema/sends.ts +2 -0
  61. package/src/types.ts +23 -0
  62. package/src/utils/broadcast.ts +16 -3
  63. package/src/utils/delivery-claim.ts +66 -0
  64. package/src/utils/runtime-secrets.ts +144 -0
  65. package/src/utils/send.ts +27 -5
  66. package/src/utils/tokens.ts +16 -5
  67. package/src/utils/urls.ts +9 -6
  68. package/src/virtual.d.ts +4 -2
  69. package/src/vite-plugin.ts +18 -1
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/utils/tokens.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/utils/tokens.ts"],"names":[],"mappings":"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"}
@@ -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' | 'signingSecret' | 'unsubscribePath'>, subscriberId?: string): Promise<{
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
  }>;
@@ -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;AAG1D,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,CACZ,qBAAqB,EACrB,iBAAiB,GAAG,SAAS,GAAG,eAAe,GAAG,iBAAiB,CACnE,EACD,YAAY,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAC,CAwB7D"}
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"}
@@ -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(options.signingSecret, {
20
+ generateToken(signingSecret, {
17
21
  subscriberId,
18
22
  action: 'unsubscribe',
23
+ host,
19
24
  }),
20
- generateToken(options.signingSecret, {
25
+ generateToken(signingSecret, {
21
26
  subscriberId,
22
27
  action: 'preferences',
28
+ host,
23
29
  }),
24
30
  ]);
25
31
  return {
@@ -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,OAGC,EACD,YAAqB;IAErB,IAAI,CAAC,YAAY,EAAE,CAAC;QACnB,OAAO,EAAE,cAAc,EAAE,EAAE,EAAE,cAAc,EAAE,EAAE,EAAE,CAAA;IAClD,CAAC;IAED,MAAM,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAC9D,aAAa,CAAC,OAAO,CAAC,aAAa,EAAE;YACpC,YAAY;YACZ,MAAM,EAAE,aAAa;SACrB,CAAC;QACF,aAAa,CAAC,OAAO,CAAC,aAAa,EAAE;YACpC,YAAY;YACZ,MAAM,EAAE,aAAa;SACrB,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"}
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"}
@@ -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,CAc5E"}
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"}
@@ -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 `export const config = ${JSON.stringify(config)};`;
25
+ return renderMailerConfigModule(config, command);
14
26
  }
15
27
  },
16
28
  };
@@ -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,yBAAyB,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,GAAG,CAAA;YAC1D,CAAC;QACF,CAAC;KACD,CAAA;AACF,CAAC"}
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.4.11",
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": "^6.1.10",
88
+ "astro": "7.3.1",
89
89
  "drizzle-kit": "^0.30.0",
90
90
  "typescript": "^5.7.0",
91
- "vite": "^7.3.2",
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
- turnstileSecretKey: z.string(),
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
- signingSecret: z.string(),
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'),
@@ -1,18 +1,82 @@
1
1
  import { drizzle } from 'drizzle-orm/d1'
2
2
  import { probeMailerSendsSchema } from '../_internal/schema-probe.js'
3
- import type { EmailQueueMessage, SiteConfigLookup, SiteMailerConfig } from '../types.js'
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 ?? ''
@@ -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(config.signingSecret, token)
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 payload = await verifyToken(config.signingSecret, token);
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(config.signingSecret, {
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 });
@@ -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: config.turnstileSecretKey,
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(config.signingSecret, {
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(config.signingSecret, token)
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(config.signingSecret, {
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 })
@@ -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
 
@@ -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(options.signingSecret, {
351
+ generateToken(signingSecret, {
343
352
  subscriberId: recipient.subscriberId,
344
353
  action: 'unsubscribe',
354
+ host,
345
355
  }),
346
- generateToken(options.signingSecret, {
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