@growth-labs/mailer 0.2.0 → 0.2.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 (57) hide show
  1. package/README.md +28 -4
  2. package/dist/options.d.ts +3 -0
  3. package/dist/options.d.ts.map +1 -1
  4. package/dist/options.js +1 -0
  5. package/dist/options.js.map +1 -1
  6. package/dist/queue/consumer.d.ts +1 -0
  7. package/dist/queue/consumer.d.ts.map +1 -1
  8. package/dist/queue/consumer.js +22 -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 +15 -4
  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 +20 -4
  15. package/dist/routes/subscribe.js.map +1 -1
  16. package/dist/routes/track-click.d.ts.map +1 -1
  17. package/dist/routes/track-click.js +13 -4
  18. package/dist/routes/track-click.js.map +1 -1
  19. package/dist/routes/track-open.d.ts.map +1 -1
  20. package/dist/routes/track-open.js +13 -4
  21. package/dist/routes/track-open.js.map +1 -1
  22. package/dist/routes/unsubscribe.d.ts.map +1 -1
  23. package/dist/routes/unsubscribe.js +21 -8
  24. package/dist/routes/unsubscribe.js.map +1 -1
  25. package/dist/routes/webhook.d.ts.map +1 -1
  26. package/dist/routes/webhook.js +28 -5
  27. package/dist/routes/webhook.js.map +1 -1
  28. package/dist/utils/analytics.d.ts +24 -0
  29. package/dist/utils/analytics.d.ts.map +1 -0
  30. package/dist/utils/analytics.js +74 -0
  31. package/dist/utils/analytics.js.map +1 -0
  32. package/dist/utils/bindings.d.ts +4 -10
  33. package/dist/utils/bindings.d.ts.map +1 -1
  34. package/dist/utils/bindings.js +7 -7
  35. package/dist/utils/bindings.js.map +1 -1
  36. package/dist/utils/index.d.ts +2 -1
  37. package/dist/utils/index.d.ts.map +1 -1
  38. package/dist/utils/index.js +2 -1
  39. package/dist/utils/index.js.map +1 -1
  40. package/dist/utils/providers.js +1 -1
  41. package/dist/utils/providers.js.map +1 -1
  42. package/package.json +7 -1
  43. package/src/cloudflare-workers.d.ts +3 -0
  44. package/src/options.ts +1 -0
  45. package/src/queue/consumer.ts +27 -1
  46. package/src/routes/confirm.ts +16 -5
  47. package/src/routes/preferences.astro +5 -9
  48. package/src/routes/subscribe.ts +21 -5
  49. package/src/routes/track-click.ts +14 -8
  50. package/src/routes/track-open.ts +14 -8
  51. package/src/routes/unsubscribe.ts +26 -9
  52. package/src/routes/webhook.ts +30 -10
  53. package/src/utils/analytics.ts +118 -0
  54. package/src/utils/bindings.ts +9 -11
  55. package/src/utils/index.ts +6 -5
  56. package/src/utils/providers.ts +1 -1
  57. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,24 @@
1
+ import type { ResolvedMailerOptions } from '../options.js';
2
+ export type MailerAnalyticsEvent = 'newsletter_subscribed' | 'newsletter_confirmed' | 'newsletter_unsubscribed' | 'newsletter_opened' | 'newsletter_clicked' | 'newsletter_delivered' | 'newsletter_bounced' | 'newsletter_complained' | 'newsletter_sent' | 'newsletter_send_failed';
3
+ interface AnalyticsContext {
4
+ locals?: {
5
+ cfContext?: {
6
+ waitUntil(promise: Promise<unknown>): void;
7
+ };
8
+ };
9
+ }
10
+ interface EmitMailerAnalyticsOptions {
11
+ request?: Request;
12
+ context?: AnalyticsContext;
13
+ label?: Record<string, unknown>;
14
+ contentSlug?: string;
15
+ eventValue?: number;
16
+ }
17
+ export declare function emitMailerAnalyticsEvent(options: ResolvedMailerOptions, bindingsEnv: Record<string, unknown>, eventName: MailerAnalyticsEvent, emitOptions?: EmitMailerAnalyticsOptions): boolean;
18
+ export declare function buildMailerAnalyticsDataPoint(options: ResolvedMailerOptions, eventName: MailerAnalyticsEvent, emitOptions?: EmitMailerAnalyticsOptions): {
19
+ blobs: string[];
20
+ doubles: number[];
21
+ indexes: string[];
22
+ };
23
+ export {};
24
+ //# sourceMappingURL=analytics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../../src/utils/analytics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAE1D,MAAM,MAAM,oBAAoB,GAC7B,uBAAuB,GACvB,sBAAsB,GACtB,yBAAyB,GACzB,mBAAmB,GACnB,oBAAoB,GACpB,sBAAsB,GACtB,oBAAoB,GACpB,uBAAuB,GACvB,iBAAiB,GACjB,wBAAwB,CAAA;AAM3B,UAAU,gBAAgB;IACzB,MAAM,CAAC,EAAE;QACR,SAAS,CAAC,EAAE;YACX,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;SAC1C,CAAA;KACD,CAAA;CACD;AAED,UAAU,0BAA0B;IACnC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,OAAO,CAAC,EAAE,gBAAgB,CAAA;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,wBAAwB,CACvC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpC,SAAS,EAAE,oBAAoB,EAC/B,WAAW,GAAE,0BAA+B,GAC1C,OAAO,CAeT;AAED,wBAAgB,6BAA6B,CAC5C,OAAO,EAAE,qBAAqB,EAC9B,SAAS,EAAE,oBAAoB,EAC/B,WAAW,GAAE,0BAA+B,GAC1C;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CA+B3D"}
@@ -0,0 +1,74 @@
1
+ export function emitMailerAnalyticsEvent(options, bindingsEnv, eventName, emitOptions = {}) {
2
+ if (!options.analyticsEnabled)
3
+ return false;
4
+ const analyticsBinding = bindingsEnv[options.analyticsBinding];
5
+ if (!analyticsBinding?.writeDataPoint)
6
+ return false;
7
+ const dataPoint = buildMailerAnalyticsDataPoint(options, eventName, emitOptions);
8
+ const write = analyticsBinding.writeDataPoint(dataPoint);
9
+ const waitUntil = emitOptions.context?.locals?.cfContext?.waitUntil;
10
+ if (waitUntil) {
11
+ waitUntil(write.catch(() => undefined));
12
+ }
13
+ else {
14
+ void write.catch(() => undefined);
15
+ }
16
+ return true;
17
+ }
18
+ export function buildMailerAnalyticsDataPoint(options, eventName, emitOptions = {}) {
19
+ const url = emitOptions.request ? new URL(emitOptions.request.url) : new URL(options.siteUrl);
20
+ const siteId = siteIdFromUrl(options.siteUrl);
21
+ const label = emitOptions.label ? JSON.stringify(emitOptions.label) : '';
22
+ return {
23
+ blobs: [
24
+ eventName,
25
+ siteId,
26
+ '',
27
+ '',
28
+ url.toString(),
29
+ url.pathname,
30
+ emitOptions.request?.headers.get('referer') ?? '',
31
+ '',
32
+ '',
33
+ '',
34
+ '',
35
+ emitOptions.request?.headers.get('cf-ipcountry') ?? '',
36
+ '',
37
+ '',
38
+ '',
39
+ emitOptions.contentSlug ?? '',
40
+ 'newsletter',
41
+ categoryForMailerEvent(eventName),
42
+ label,
43
+ 'false',
44
+ ],
45
+ doubles: [Date.now(), emitOptions.eventValue ?? 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
46
+ indexes: [eventName],
47
+ };
48
+ }
49
+ function siteIdFromUrl(siteUrl) {
50
+ try {
51
+ return new URL(siteUrl).hostname.replace(/^www\./, '');
52
+ }
53
+ catch {
54
+ return siteUrl;
55
+ }
56
+ }
57
+ function categoryForMailerEvent(eventName) {
58
+ switch (eventName) {
59
+ case 'newsletter_subscribed':
60
+ case 'newsletter_confirmed':
61
+ return 'conversion';
62
+ case 'newsletter_opened':
63
+ case 'newsletter_clicked':
64
+ return 'interaction';
65
+ case 'newsletter_delivered':
66
+ case 'newsletter_bounced':
67
+ case 'newsletter_complained':
68
+ case 'newsletter_sent':
69
+ case 'newsletter_send_failed':
70
+ case 'newsletter_unsubscribed':
71
+ return 'newsletter';
72
+ }
73
+ }
74
+ //# sourceMappingURL=analytics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analytics.js","sourceRoot":"","sources":["../../src/utils/analytics.ts"],"names":[],"mappings":"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"}
@@ -1,18 +1,12 @@
1
1
  import { drizzle } from 'drizzle-orm/d1';
2
2
  type DrizzleDB = ReturnType<typeof drizzle>;
3
- export interface RuntimeLocals {
4
- runtime?: {
5
- env: Record<string, unknown>;
6
- };
7
- }
8
3
  /**
9
- * Resolve Cloudflare bindings from the runtime environment object.
4
+ * Resolve Cloudflare bindings from the Astro 6 Cloudflare Workers env object.
10
5
  *
11
- * Route handlers call this with `context.locals.runtime.env` (the standard
12
- * Astro 6 + @astrojs/cloudflare adapter pattern). Binding names come from
13
- * the virtual config so they stay in sync with the consumer's `astro.config`.
6
+ * Binding names come from the virtual config so they stay in sync with the
7
+ * consumer's `astro.config`.
14
8
  */
15
- export declare function resolveBindings(runtimeEnv: Record<string, unknown>): {
9
+ export declare function resolveBindings(bindingsEnv?: Record<string, unknown>): {
16
10
  db: DrizzleDB;
17
11
  queue: Queue;
18
12
  };
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../../src/utils/bindings.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAExC,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAA;AAE3C,MAAM,WAAW,aAAa;IAC7B,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CAC1C;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IACrE,EAAE,EAAE,SAAS,CAAA;IACb,KAAK,EAAE,KAAK,CAAA;CACZ,CAQA"}
1
+ {"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../../src/utils/bindings.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAExC,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAA;AAE3C;;;;;GAKG;AACH,wBAAgB,eAAe,CAC9B,WAAW,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAA4C,GAC7E;IACF,EAAE,EAAE,SAAS,CAAA;IACb,KAAK,EAAE,KAAK,CAAA;CACZ,CAQA"}
@@ -1,15 +1,15 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers';
1
2
  import { config } from 'virtual:growth-labs/mailer/config';
2
3
  import { drizzle } from 'drizzle-orm/d1';
3
4
  /**
4
- * Resolve Cloudflare bindings from the runtime environment object.
5
+ * Resolve Cloudflare bindings from the Astro 6 Cloudflare Workers env object.
5
6
  *
6
- * Route handlers call this with `context.locals.runtime.env` (the standard
7
- * Astro 6 + @astrojs/cloudflare adapter pattern). Binding names come from
8
- * the virtual config so they stay in sync with the consumer's `astro.config`.
7
+ * Binding names come from the virtual config so they stay in sync with the
8
+ * consumer's `astro.config`.
9
9
  */
10
- export function resolveBindings(runtimeEnv) {
11
- const d1 = runtimeEnv[config.d1Binding];
12
- const queue = runtimeEnv[config.queueBinding];
10
+ export function resolveBindings(bindingsEnv = cloudflareEnv) {
11
+ const d1 = bindingsEnv[config.d1Binding];
12
+ const queue = bindingsEnv[config.queueBinding];
13
13
  if (!d1)
14
14
  throw new Error(`[mailer] D1 binding "${config.d1Binding}" not found`);
15
15
  if (!queue)
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.js","sourceRoot":"","sources":["../../src/utils/bindings.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,mCAAmC,CAAA;AAC1D,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAQxC;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,UAAmC;IAIlE,MAAM,EAAE,GAAG,UAAU,CAAC,MAAM,CAAC,SAAS,CAAe,CAAA;IACrD,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,CAAC,YAAY,CAAU,CAAA;IAEtD,IAAI,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,MAAM,CAAC,SAAS,aAAa,CAAC,CAAA;IAC/E,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,MAAM,CAAC,YAAY,aAAa,CAAC,CAAA;IAExF,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAA;AAClC,CAAC"}
1
+ {"version":3,"file":"bindings.js","sourceRoot":"","sources":["../../src/utils/bindings.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,IAAI,aAAa,EAAE,MAAM,oBAAoB,CAAA;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,mCAAmC,CAAA;AAC1D,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAIxC;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAC9B,cAAuC,aAAwC;IAK/E,MAAM,EAAE,GAAG,WAAW,CAAC,MAAM,CAAC,SAAS,CAAe,CAAA;IACtD,MAAM,KAAK,GAAG,WAAW,CAAC,MAAM,CAAC,YAAY,CAAU,CAAA;IAEvD,IAAI,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,MAAM,CAAC,SAAS,aAAa,CAAC,CAAA;IAC/E,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,MAAM,CAAC,YAAY,aAAa,CAAC,CAAA;IAExF,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAA;AAClC,CAAC"}
@@ -1,6 +1,7 @@
1
+ export { buildMailerAnalyticsDataPoint, emitMailerAnalyticsEvent, type MailerAnalyticsEvent, } from './analytics.js';
1
2
  export { handleBounce, handleComplaint, handleDelivery, updateSendStatus, } from './bounce.js';
2
3
  export type { CloudflareEmailSender } from './providers.js';
3
- export { CloudflareEmailProvider, getProvider, sleep, } from './providers.js';
4
+ export { CloudflareEmailProvider, getProvider, sleep } from './providers.js';
4
5
  export type { CampaignSchedule, DigestSchedule } from './scheduling.js';
5
6
  export { executeCampaignSchedule, executeDigestSchedule, prepareCampaign, prepareDigest, sendBatchCampaigns, } from './scheduling.js';
6
7
  export type { MailerEnv } from './send.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,GAChB,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EACN,uBAAuB,EACvB,WAAW,EACX,KAAK,GACL,MAAM,gBAAgB,CAAA;AACvB,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACvE,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACvE,OAAO,EACN,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACN,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EACN,mBAAmB,EACnB,uBAAuB,EACvB,eAAe,GACf,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,6BAA6B,EAC7B,wBAAwB,EACxB,KAAK,oBAAoB,GACzB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACN,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,GAChB,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAE,uBAAuB,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AAC5E,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACvE,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACvE,OAAO,EACN,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACN,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EACN,mBAAmB,EACnB,uBAAuB,EACvB,eAAe,GACf,MAAM,eAAe,CAAA"}
@@ -1,5 +1,6 @@
1
+ export { buildMailerAnalyticsDataPoint, emitMailerAnalyticsEvent, } from './analytics.js';
1
2
  export { handleBounce, handleComplaint, handleDelivery, updateSendStatus, } from './bounce.js';
2
- export { CloudflareEmailProvider, getProvider, sleep, } from './providers.js';
3
+ export { CloudflareEmailProvider, getProvider, sleep } from './providers.js';
3
4
  export { executeCampaignSchedule, executeDigestSchedule, prepareCampaign, prepareDigest, sendBatchCampaigns, } from './scheduling.js';
4
5
  export { sendCampaign, sendDigest, sendTransactional } from './send.js';
5
6
  export { confirmSubscriber, countSubscribers, createSubscriber, getSubscriberBatch, getSubscriberByEmail, getSubscriberById, unsubscribeSubscriber, updatePreferences, } from './subscribers.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,GAChB,MAAM,aAAa,CAAA;AAEpB,OAAO,EACN,uBAAuB,EACvB,WAAW,EACX,KAAK,GACL,MAAM,gBAAgB,CAAA;AAEvB,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,iBAAiB,CAAA;AAExB,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACvE,OAAO,EACN,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACN,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EACN,mBAAmB,EACnB,uBAAuB,EACvB,eAAe,GACf,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,6BAA6B,EAC7B,wBAAwB,GAExB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACN,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,GAChB,MAAM,aAAa,CAAA;AAEpB,OAAO,EAAE,uBAAuB,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AAE5E,OAAO,EACN,uBAAuB,EACvB,qBAAqB,EACrB,eAAe,EACf,aAAa,EACb,kBAAkB,GAClB,MAAM,iBAAiB,CAAA;AAExB,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACvE,OAAO,EACN,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,iBAAiB,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACN,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,GACX,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EACN,mBAAmB,EACnB,uBAAuB,EACvB,eAAe,GACf,MAAM,eAAe,CAAA"}
@@ -26,7 +26,7 @@ export class CloudflareEmailProvider {
26
26
  }
27
27
  }
28
28
  }
29
- // ─── Factory functions ───
29
+ // ─── Factory function ───
30
30
  export function getProvider(emailSender) {
31
31
  return new CloudflareEmailProvider(emailSender);
32
32
  }
@@ -1 +1 @@
1
- {"version":3,"file":"providers.js","sourceRoot":"","sources":["../../src/utils/providers.ts"],"names":[],"mappings":"AAaA,kCAAkC;AAElC,MAAM,OAAO,uBAAuB;IAC1B,IAAI,GAAG,YAAY,CAAA;IACX,MAAM,CAAuB;IAE9C,YAAY,WAAkC;QAC7C,IAAI,CAAC,MAAM,GAAG,WAAW,CAAA;IAC1B,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,KAAoB;QAC9B,IAAI,CAAC;YACJ,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC;gBACtB,EAAE,EAAE,KAAK,CAAC,EAAE;gBACZ,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,OAAO,EAAE,KAAK,CAAC,OAAO;gBACtB,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;aACnD,CAAC,CAAA;YAEF,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,CAAA;QAC3C,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;YAChE,MAAM,WAAW,GAAG,sBAAsB,CAAC,OAAO,CAAC,CAAA;YAEnD,OAAO;gBACN,OAAO,EAAE,KAAK;gBACd,KAAK,EAAE,OAAO;gBACd,SAAS,EAAE,WAAW;aACtB,CAAA;QACF,CAAC;IACF,CAAC;CACD;AAED,4BAA4B;AAE5B,MAAM,UAAU,WAAW,CAAC,WAAkC;IAC7D,OAAO,IAAI,uBAAuB,CAAC,WAAW,CAAC,CAAA;AAChD,CAAC;AAED,kBAAkB;AAElB,MAAM,UAAU,KAAK,CAAC,EAAU;IAC/B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAA;AACzD,CAAC;AAED,SAAS,sBAAsB,CAAC,OAAe;IAC9C,MAAM,QAAQ,GAAG;QAChB,SAAS;QACT,SAAS;QACT,cAAc;QACd,YAAY;QACZ,WAAW;QACX,QAAQ;QACR,GAAG;QACH,cAAc;QACd,qBAAqB;QACrB,iBAAiB;KACjB,CAAA;IACD,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,EAAE,CAAA;IACnC,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAA;AAC/C,CAAC"}
1
+ {"version":3,"file":"providers.js","sourceRoot":"","sources":["../../src/utils/providers.ts"],"names":[],"mappings":"AAaA,kCAAkC;AAElC,MAAM,OAAO,uBAAuB;IAC1B,IAAI,GAAG,YAAY,CAAA;IACX,MAAM,CAAuB;IAE9C,YAAY,WAAkC;QAC7C,IAAI,CAAC,MAAM,GAAG,WAAW,CAAA;IAC1B,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,KAAoB;QAC9B,IAAI,CAAC;YACJ,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC;gBACtB,EAAE,EAAE,KAAK,CAAC,EAAE;gBACZ,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,OAAO,EAAE,KAAK,CAAC,OAAO;gBACtB,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;aACnD,CAAC,CAAA;YAEF,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,CAAA;QAC3C,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;YAChE,MAAM,WAAW,GAAG,sBAAsB,CAAC,OAAO,CAAC,CAAA;YAEnD,OAAO;gBACN,OAAO,EAAE,KAAK;gBACd,KAAK,EAAE,OAAO;gBACd,SAAS,EAAE,WAAW;aACtB,CAAA;QACF,CAAC;IACF,CAAC;CACD;AAED,2BAA2B;AAE3B,MAAM,UAAU,WAAW,CAAC,WAAkC;IAC7D,OAAO,IAAI,uBAAuB,CAAC,WAAW,CAAC,CAAA;AAChD,CAAC;AAED,kBAAkB;AAElB,MAAM,UAAU,KAAK,CAAC,EAAU;IAC/B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAA;AACzD,CAAC;AAED,SAAS,sBAAsB,CAAC,OAAe;IAC9C,MAAM,QAAQ,GAAG;QAChB,SAAS;QACT,SAAS;QACT,cAAc;QACd,YAAY;QACZ,WAAW;QACX,QAAQ;QACR,GAAG;QACH,cAAc;QACd,qBAAqB;QACrB,iBAAiB;KACjB,CAAA;IACD,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,EAAE,CAAA;IACnC,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAA;AAC/C,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@growth-labs/mailer",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
+ "types": "./dist/index.d.ts",
5
6
  "exports": {
6
7
  ".": {
7
8
  "types": "./dist/index.d.ts",
@@ -52,6 +53,10 @@
52
53
  "src",
53
54
  "README.md"
54
55
  ],
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "registry": "https://registry.npmjs.org/"
59
+ },
55
60
  "peerDependencies": {
56
61
  "astro": "^6.0.0"
57
62
  },
@@ -75,6 +80,7 @@
75
80
  },
76
81
  "scripts": {
77
82
  "build": "tsc",
83
+ "check": "biome check src/",
78
84
  "test": "vitest run",
79
85
  "test:watch": "vitest"
80
86
  }
@@ -0,0 +1,3 @@
1
+ declare module 'cloudflare:workers' {
2
+ export const env: Record<string, unknown>
3
+ }
package/src/options.ts CHANGED
@@ -50,6 +50,7 @@ export const mailerOptionsSchema = z.object({
50
50
 
51
51
  // ─── Optional peer: analytics ───
52
52
  analyticsEnabled: z.boolean().default(false),
53
+ analyticsBinding: z.string().default('ANALYTICS'),
53
54
  })
54
55
 
55
56
  export type MailerOptions = z.input<typeof mailerOptionsSchema>
@@ -1,13 +1,18 @@
1
1
  import { drizzle } from 'drizzle-orm/d1'
2
2
  import type { ResolvedMailerOptions } from '../options.js'
3
3
  import type { EmailProvider, EmailQueueMessage } from '../types.js'
4
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
4
5
  import { updateSendStatus } from '../utils/bounce.js'
5
6
  import type { CloudflareEmailSender } from '../utils/providers.js'
6
7
  import { CloudflareEmailProvider, sleep } from '../utils/providers.js'
7
8
 
8
9
  export async function handleEmailQueue(
9
10
  batch: MessageBatch<EmailQueueMessage>,
10
- env: { DB: D1Database; EMAIL_SENDER?: CloudflareEmailSender },
11
+ env: {
12
+ DB: D1Database
13
+ EMAIL_SENDER?: CloudflareEmailSender
14
+ [key: string]: unknown
15
+ },
11
16
  options: ResolvedMailerOptions,
12
17
  ): Promise<void> {
13
18
  const db = drizzle(env.DB)
@@ -68,11 +73,32 @@ export async function handleEmailQueue(
68
73
  await updateSendStatus(db, recipient.trackingId, 'sent', {
69
74
  sentAt: new Date().toISOString(),
70
75
  })
76
+ emitMailerAnalyticsEvent(options, env, 'newsletter_sent', {
77
+ contentSlug: recipient.trackingId,
78
+ label: {
79
+ trackingId: recipient.trackingId,
80
+ subscriberId: recipient.subscriberId,
81
+ email: recipient.email,
82
+ campaignId: message.body.campaignId,
83
+ type,
84
+ },
85
+ })
71
86
  } else {
72
87
  await updateSendStatus(db, recipient.trackingId, 'bounced', {
73
88
  bouncedAt: new Date().toISOString(),
74
89
  bounceType: 'hard',
75
90
  })
91
+ emitMailerAnalyticsEvent(options, env, 'newsletter_send_failed', {
92
+ contentSlug: recipient.trackingId,
93
+ label: {
94
+ trackingId: recipient.trackingId,
95
+ subscriberId: recipient.subscriberId,
96
+ email: recipient.email,
97
+ campaignId: message.body.campaignId,
98
+ type,
99
+ error: result.error,
100
+ },
101
+ })
76
102
  console.error(`[mailer] Send failed for ${recipient.email}: ${result.error}`)
77
103
  }
78
104
  }
@@ -1,13 +1,15 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { drizzle } from 'drizzle-orm/d1'
4
- import type { RuntimeLocals } from '../utils/bindings.js'
5
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
5
6
  import type { MailerEnv } from '../utils/send.js'
6
7
  import { sendTransactional } from '../utils/send.js'
7
8
  import { confirmSubscriber, getSubscriberById } from '../utils/subscribers.js'
8
9
  import { verifyToken } from '../utils/tokens.js'
9
10
 
10
- export const GET: APIRoute = async ({ url, locals }) => {
11
+ export const GET: APIRoute = async (context) => {
12
+ const { url } = context
11
13
  const token = url.searchParams.get('token')
12
14
  if (!token) {
13
15
  return Response.json({ error: 'Missing token' }, { status: 400 })
@@ -20,9 +22,9 @@ export const GET: APIRoute = async ({ url, locals }) => {
20
22
  }
21
23
 
22
24
  // Resolve bindings
23
- const runtimeEnv = (locals as RuntimeLocals).runtime?.env as Record<string, unknown>
24
- const d1 = runtimeEnv[config.d1Binding] as D1Database
25
- const queue = runtimeEnv[config.queueBinding] as Queue
25
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
26
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
27
+ const queue = bindingsEnv[config.queueBinding] as Queue
26
28
  const db = drizzle(d1)
27
29
  const env: MailerEnv = { DB: d1, QUEUE: queue }
28
30
 
@@ -58,6 +60,15 @@ export const GET: APIRoute = async ({ url, locals }) => {
58
60
  data: { name: subscriber.name },
59
61
  })
60
62
 
63
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_confirmed', {
64
+ context,
65
+ contentSlug: subscriber.id,
66
+ label: {
67
+ subscriberId: subscriber.id,
68
+ email: subscriber.email,
69
+ },
70
+ })
71
+
61
72
  // Redirect to site with confirmed flag
62
73
  return new Response(null, {
63
74
  status: 302,
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { config } from "virtual:growth-labs/mailer/config";
3
+ import { env as cloudflareEnv } from "cloudflare:workers";
3
4
  import { drizzle } from "drizzle-orm/d1";
4
5
  import PreferenceCenter from "../components/PreferenceCenter.astro";
5
6
  import {
@@ -50,16 +51,11 @@ if (Astro.request.method === "POST" && payload.action !== "preferences") {
50
51
  return new Response("Invalid or expired token", { status: 400 });
51
52
  }
52
53
 
53
- const runtimeEnv = (
54
- Astro.locals as Record<string, unknown> & {
55
- runtime?: { env: Record<string, unknown> };
56
- }
57
- ).runtime?.env;
58
- if (!runtimeEnv) {
59
- return new Response("Runtime not available", { status: 500 });
54
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>;
55
+ const d1 = bindingsEnv[config.d1Binding] as D1Database;
56
+ if (!d1) {
57
+ return new Response("D1 binding not available", { status: 500 });
60
58
  }
61
-
62
- const d1 = runtimeEnv[config.d1Binding] as D1Database;
63
59
  const db = drizzle(d1);
64
60
 
65
61
  // Handle POST (form submission)
@@ -1,13 +1,15 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { drizzle } from 'drizzle-orm/d1'
4
- import type { RuntimeLocals } from '../utils/bindings.js'
5
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
5
6
  import type { MailerEnv } from '../utils/send.js'
6
7
  import { sendTransactional } from '../utils/send.js'
7
8
  import { createSubscriber } from '../utils/subscribers.js'
8
9
  import { generateToken } from '../utils/tokens.js'
9
10
 
10
- export const POST: APIRoute = async ({ request, locals }) => {
11
+ export const POST: APIRoute = async (context) => {
12
+ const { request } = context
11
13
  // 1. Parse request body
12
14
  const body = (await request.json()) as {
13
15
  email?: string
@@ -48,9 +50,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
48
50
  }
49
51
 
50
52
  // 5. Resolve bindings
51
- const runtimeEnv = (locals as RuntimeLocals).runtime?.env as Record<string, unknown>
52
- const d1 = runtimeEnv[config.d1Binding] as D1Database
53
- const queue = runtimeEnv[config.queueBinding] as Queue
53
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
54
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
55
+ const queue = bindingsEnv[config.queueBinding] as Queue
54
56
  const db = drizzle(d1)
55
57
  const env: MailerEnv = { DB: d1, QUEUE: queue }
56
58
 
@@ -92,6 +94,20 @@ export const POST: APIRoute = async ({ request, locals }) => {
92
94
  })
93
95
  }
94
96
 
97
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_subscribed', {
98
+ request,
99
+ context,
100
+ contentSlug: subscriber.id,
101
+ label: {
102
+ subscriberId: subscriber.id,
103
+ email: subscriber.email,
104
+ source: body.source ?? 'form',
105
+ preferences: body.preferences ?? [],
106
+ requiresConfirmation: config.doubleOptIn,
107
+ isNew,
108
+ },
109
+ })
110
+
95
111
  // Return success (always 200 to avoid email enumeration)
96
112
  return Response.json({
97
113
  success: true,
@@ -1,10 +1,13 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { and, eq, inArray } from 'drizzle-orm'
4
5
  import { drizzle } from 'drizzle-orm/d1'
5
6
  import { emailSends } from '../schema.js'
7
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
6
8
 
7
- export const GET: APIRoute = async ({ params, url, locals }) => {
9
+ export const GET: APIRoute = async (context) => {
10
+ const { params, request, url } = context
8
11
  const trackingId = params.trackingId
9
12
  const destination = url.searchParams.get('url')
10
13
 
@@ -24,13 +27,9 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
24
27
 
25
28
  // Update status to 'clicked' only when it hasn't already reached 'clicked'
26
29
  try {
27
- const runtimeEnv = (
28
- locals as Record<string, unknown> & {
29
- runtime?: { env: Record<string, unknown> }
30
- }
31
- ).runtime?.env
32
- if (runtimeEnv) {
33
- const d1 = runtimeEnv[config.d1Binding] as D1Database
30
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
31
+ if (bindingsEnv) {
32
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
34
33
  const db = drizzle(d1)
35
34
  await db
36
35
  .update(emailSends)
@@ -49,6 +48,13 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
49
48
  // Never fail the redirect on DB errors
50
49
  }
51
50
 
51
+ emitMailerAnalyticsEvent(config, cloudflareEnv as Record<string, unknown>, 'newsletter_clicked', {
52
+ request,
53
+ context,
54
+ contentSlug: trackingId,
55
+ label: { trackingId, destination },
56
+ })
57
+
52
58
  // 302 redirect to original destination
53
59
  return new Response(null, {
54
60
  status: 302,
@@ -1,11 +1,14 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { and, eq, inArray } from 'drizzle-orm'
4
5
  import { drizzle } from 'drizzle-orm/d1'
5
6
  import { emailSends } from '../schema.js'
7
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
6
8
  import { TRANSPARENT_GIF } from '../utils/tracking.js'
7
9
 
8
- export const GET: APIRoute = async ({ params, locals }) => {
10
+ export const GET: APIRoute = async (context) => {
11
+ const { params, request } = context
9
12
  const trackingId = params.trackingId
10
13
  if (!trackingId) {
11
14
  return new Response(null, { status: 400 })
@@ -14,13 +17,9 @@ export const GET: APIRoute = async ({ params, locals }) => {
14
17
  // Update status to 'opened' only when currently 'sent' or 'delivered'
15
18
  // to avoid downgrading from 'clicked'.
16
19
  try {
17
- const runtimeEnv = (
18
- locals as Record<string, unknown> & {
19
- runtime?: { env: Record<string, unknown> }
20
- }
21
- ).runtime?.env
22
- if (runtimeEnv) {
23
- const d1 = runtimeEnv[config.d1Binding] as D1Database
20
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
21
+ if (bindingsEnv) {
22
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
24
23
  const db = drizzle(d1)
25
24
  await db
26
25
  .update(emailSends)
@@ -39,6 +38,13 @@ export const GET: APIRoute = async ({ params, locals }) => {
39
38
  // Never fail the pixel response on DB errors
40
39
  }
41
40
 
41
+ emitMailerAnalyticsEvent(config, cloudflareEnv as Record<string, unknown>, 'newsletter_opened', {
42
+ request,
43
+ context,
44
+ contentSlug: trackingId,
45
+ label: { trackingId },
46
+ })
47
+
42
48
  // 1x1 transparent GIF
43
49
  return new Response(TRANSPARENT_GIF, {
44
50
  status: 200,
@@ -1,14 +1,19 @@
1
+ import { env as cloudflareEnv } from 'cloudflare:workers'
1
2
  import { config } from 'virtual:growth-labs/mailer/config'
2
3
  import type { APIRoute } from 'astro'
3
4
  import { drizzle } from 'drizzle-orm/d1'
4
- import type { RuntimeLocals } from '../utils/bindings.js'
5
+ import { emitMailerAnalyticsEvent } from '../utils/analytics.js'
5
6
  import type { MailerEnv } from '../utils/send.js'
6
7
  import { sendTransactional } from '../utils/send.js'
7
8
  import { getSubscriberById, unsubscribeSubscriber } from '../utils/subscribers.js'
8
9
  import { generateToken, verifyToken } from '../utils/tokens.js'
9
10
  import { buildSiteUrl } from '../utils/urls.js'
10
11
 
11
- async function processUnsubscribe(locals: RuntimeLocals, token: string) {
12
+ async function processUnsubscribe(
13
+ token: string,
14
+ context?: Parameters<APIRoute>[0],
15
+ request?: Request,
16
+ ) {
12
17
  // Verify token
13
18
  const payload = await verifyToken(config.signingSecret, token)
14
19
  if (!payload || payload.action !== 'unsubscribe') {
@@ -16,9 +21,9 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
16
21
  }
17
22
 
18
23
  // Resolve bindings
19
- const runtimeEnv = locals.runtime?.env as Record<string, unknown>
20
- const d1 = runtimeEnv[config.d1Binding] as D1Database
21
- const queue = runtimeEnv[config.queueBinding] as Queue
24
+ const bindingsEnv = cloudflareEnv as Record<string, unknown>
25
+ const d1 = bindingsEnv[config.d1Binding] as D1Database
26
+ const queue = bindingsEnv[config.queueBinding] as Queue
22
27
  const db = drizzle(d1)
23
28
  const env: MailerEnv = { DB: d1, QUEUE: queue }
24
29
 
@@ -43,6 +48,16 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
43
48
  },
44
49
  })
45
50
 
51
+ emitMailerAnalyticsEvent(config, bindingsEnv, 'newsletter_unsubscribed', {
52
+ request,
53
+ context,
54
+ contentSlug: subscriber.id,
55
+ label: {
56
+ subscriberId: subscriber.id,
57
+ email: subscriber.email,
58
+ },
59
+ })
60
+
46
61
  return {
47
62
  success: true,
48
63
  subscriberId: payload.subscriberId,
@@ -50,13 +65,14 @@ async function processUnsubscribe(locals: RuntimeLocals, token: string) {
50
65
  }
51
66
 
52
67
  // GET: Link from email footer
53
- export const GET: APIRoute = async ({ url, locals }) => {
68
+ export const GET: APIRoute = async (context) => {
69
+ const { url, request } = context
54
70
  const token = url.searchParams.get('token')
55
71
  if (!token) {
56
72
  return Response.json({ error: 'Missing token' }, { status: 400 })
57
73
  }
58
74
 
59
- const result = await processUnsubscribe(locals as RuntimeLocals, token)
75
+ const result = await processUnsubscribe(token, context, request)
60
76
  if ('error' in result) {
61
77
  return Response.json({ error: result.error }, { status: result.status })
62
78
  }
@@ -78,7 +94,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
78
94
  }
79
95
 
80
96
  // POST: RFC 8058 List-Unsubscribe-Post
81
- export const POST: APIRoute = async ({ request, locals }) => {
97
+ export const POST: APIRoute = async (context) => {
98
+ const { request } = context
82
99
  // RFC 8058: body is "List-Unsubscribe=One-Click"
83
100
  // Token comes from List-Unsubscribe header URL
84
101
  const url = new URL(request.url)
@@ -87,7 +104,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
87
104
  return new Response('Missing token', { status: 400 })
88
105
  }
89
106
 
90
- const result = await processUnsubscribe(locals as RuntimeLocals, token)
107
+ const result = await processUnsubscribe(token, context, request)
91
108
  if ('error' in result) {
92
109
  return new Response(result.error, { status: result.status })
93
110
  }