@growth-labs/mailer 0.1.3

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 (132) hide show
  1. package/README.md +89 -0
  2. package/dist/components/index.d.ts +3 -0
  3. package/dist/components/index.d.ts.map +1 -0
  4. package/dist/components/index.js +3 -0
  5. package/dist/components/index.js.map +1 -0
  6. package/dist/index.d.ts +14 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +65 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/middleware/tracking.d.ts +3 -0
  11. package/dist/middleware/tracking.d.ts.map +1 -0
  12. package/dist/middleware/tracking.js +13 -0
  13. package/dist/middleware/tracking.js.map +1 -0
  14. package/dist/options.d.ts +160 -0
  15. package/dist/options.d.ts.map +1 -0
  16. package/dist/options.js +51 -0
  17. package/dist/options.js.map +1 -0
  18. package/dist/queue/consumer.d.ts +8 -0
  19. package/dist/queue/consumer.d.ts.map +1 -0
  20. package/dist/queue/consumer.js +83 -0
  21. package/dist/queue/consumer.js.map +1 -0
  22. package/dist/routes/confirm.d.ts +3 -0
  23. package/dist/routes/confirm.d.ts.map +1 -0
  24. package/dist/routes/confirm.js +59 -0
  25. package/dist/routes/confirm.js.map +1 -0
  26. package/dist/routes/subscribe.d.ts +3 -0
  27. package/dist/routes/subscribe.d.ts.map +1 -0
  28. package/dist/routes/subscribe.js +87 -0
  29. package/dist/routes/subscribe.js.map +1 -0
  30. package/dist/routes/track-click.d.ts +3 -0
  31. package/dist/routes/track-click.d.ts.map +1 -0
  32. package/dist/routes/track-click.js +45 -0
  33. package/dist/routes/track-click.js.map +1 -0
  34. package/dist/routes/track-open.d.ts +3 -0
  35. package/dist/routes/track-open.d.ts.map +1 -0
  36. package/dist/routes/track-open.js +40 -0
  37. package/dist/routes/track-open.js.map +1 -0
  38. package/dist/routes/unsubscribe.d.ts +4 -0
  39. package/dist/routes/unsubscribe.d.ts.map +1 -0
  40. package/dist/routes/unsubscribe.js +81 -0
  41. package/dist/routes/unsubscribe.js.map +1 -0
  42. package/dist/routes/webhook.d.ts +3 -0
  43. package/dist/routes/webhook.d.ts.map +1 -0
  44. package/dist/routes/webhook.js +30 -0
  45. package/dist/routes/webhook.js.map +1 -0
  46. package/dist/schema.d.ts +564 -0
  47. package/dist/schema.d.ts.map +1 -0
  48. package/dist/schema.js +47 -0
  49. package/dist/schema.js.map +1 -0
  50. package/dist/types.d.ts +106 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +3 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/utils/bindings.d.ts +20 -0
  55. package/dist/utils/bindings.d.ts.map +1 -0
  56. package/dist/utils/bindings.js +19 -0
  57. package/dist/utils/bindings.js.map +1 -0
  58. package/dist/utils/bounce.d.ts +29 -0
  59. package/dist/utils/bounce.d.ts.map +1 -0
  60. package/dist/utils/bounce.js +59 -0
  61. package/dist/utils/bounce.js.map +1 -0
  62. package/dist/utils/index.d.ts +12 -0
  63. package/dist/utils/index.d.ts.map +1 -0
  64. package/dist/utils/index.js +9 -0
  65. package/dist/utils/index.js.map +1 -0
  66. package/dist/utils/providers.d.ts +31 -0
  67. package/dist/utils/providers.d.ts.map +1 -0
  68. package/dist/utils/providers.js +109 -0
  69. package/dist/utils/providers.js.map +1 -0
  70. package/dist/utils/scheduling.d.ts +89 -0
  71. package/dist/utils/scheduling.d.ts.map +1 -0
  72. package/dist/utils/scheduling.js +110 -0
  73. package/dist/utils/scheduling.js.map +1 -0
  74. package/dist/utils/send.d.ts +42 -0
  75. package/dist/utils/send.d.ts.map +1 -0
  76. package/dist/utils/send.js +193 -0
  77. package/dist/utils/send.js.map +1 -0
  78. package/dist/utils/subscribers.d.ts +23 -0
  79. package/dist/utils/subscribers.d.ts.map +1 -0
  80. package/dist/utils/subscribers.js +200 -0
  81. package/dist/utils/subscribers.js.map +1 -0
  82. package/dist/utils/templates.d.ts +16 -0
  83. package/dist/utils/templates.d.ts.map +1 -0
  84. package/dist/utils/templates.js +426 -0
  85. package/dist/utils/templates.js.map +1 -0
  86. package/dist/utils/tokens.d.ts +13 -0
  87. package/dist/utils/tokens.d.ts.map +1 -0
  88. package/dist/utils/tokens.js +62 -0
  89. package/dist/utils/tokens.js.map +1 -0
  90. package/dist/utils/tracking.d.ts +26 -0
  91. package/dist/utils/tracking.d.ts.map +1 -0
  92. package/dist/utils/tracking.js +49 -0
  93. package/dist/utils/tracking.js.map +1 -0
  94. package/dist/utils/urls.d.ts +7 -0
  95. package/dist/utils/urls.d.ts.map +1 -0
  96. package/dist/utils/urls.js +34 -0
  97. package/dist/utils/urls.js.map +1 -0
  98. package/dist/vite-plugin.d.ts +4 -0
  99. package/dist/vite-plugin.d.ts.map +1 -0
  100. package/dist/vite-plugin.js +18 -0
  101. package/dist/vite-plugin.js.map +1 -0
  102. package/package.json +85 -0
  103. package/src/astro.d.ts +4 -0
  104. package/src/components/PreferenceCenter.astro +147 -0
  105. package/src/components/SubscribeForm.astro +161 -0
  106. package/src/components/index.ts +2 -0
  107. package/src/index.ts +101 -0
  108. package/src/middleware/tracking.ts +18 -0
  109. package/src/options.ts +65 -0
  110. package/src/queue/consumer.ts +99 -0
  111. package/src/routes/confirm.ts +68 -0
  112. package/src/routes/preferences.astro +137 -0
  113. package/src/routes/subscribe.ts +107 -0
  114. package/src/routes/track-click.ts +57 -0
  115. package/src/routes/track-open.ts +51 -0
  116. package/src/routes/unsubscribe.ts +96 -0
  117. package/src/routes/webhook.ts +48 -0
  118. package/src/schema.ts +56 -0
  119. package/src/types.ts +145 -0
  120. package/src/utils/bindings.ts +28 -0
  121. package/src/utils/bounce.ts +77 -0
  122. package/src/utils/index.ts +47 -0
  123. package/src/utils/providers.ts +141 -0
  124. package/src/utils/scheduling.ts +188 -0
  125. package/src/utils/send.ts +282 -0
  126. package/src/utils/subscribers.ts +277 -0
  127. package/src/utils/templates.ts +459 -0
  128. package/src/utils/tokens.ts +91 -0
  129. package/src/utils/tracking.ts +58 -0
  130. package/src/utils/urls.ts +49 -0
  131. package/src/virtual.d.ts +32 -0
  132. package/src/vite-plugin.ts +21 -0
@@ -0,0 +1,34 @@
1
+ import { generateToken } from './tokens.js';
2
+ export function buildSiteUrl(siteUrl, path, searchParams) {
3
+ const url = new URL(path, siteUrl);
4
+ for (const [key, value] of Object.entries(searchParams ?? {})) {
5
+ if (value === undefined)
6
+ continue;
7
+ url.searchParams.set(key, typeof value === 'boolean' ? String(value) : value);
8
+ }
9
+ return url.toString();
10
+ }
11
+ export async function buildSubscriberManageUrls(options, subscriberId) {
12
+ if (!subscriberId) {
13
+ return { preferencesUrl: '', unsubscribeUrl: '' };
14
+ }
15
+ const [unsubscribeToken, preferencesToken] = await Promise.all([
16
+ generateToken(options.signingSecret, {
17
+ subscriberId,
18
+ action: 'unsubscribe',
19
+ }),
20
+ generateToken(options.signingSecret, {
21
+ subscriberId,
22
+ action: 'preferences',
23
+ }),
24
+ ]);
25
+ return {
26
+ unsubscribeUrl: buildSiteUrl(options.siteUrl, options.unsubscribePath, {
27
+ token: unsubscribeToken,
28
+ }),
29
+ preferencesUrl: buildSiteUrl(options.siteUrl, options.preferencesPath, {
30
+ token: preferencesToken,
31
+ }),
32
+ };
33
+ }
34
+ //# sourceMappingURL=urls.js.map
@@ -0,0 +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"}
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from 'vite';
2
+ import type { ResolvedMailerOptions } from './options.js';
3
+ export declare function growthLabsMailerPlugin(config: ResolvedMailerOptions): Plugin;
4
+ //# sourceMappingURL=vite-plugin.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,18 @@
1
+ const VIRTUAL_MODULE_ID = 'virtual:growth-labs/mailer/config';
2
+ const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
3
+ export function growthLabsMailerPlugin(config) {
4
+ return {
5
+ name: 'growth-labs-mailer-config',
6
+ resolveId(id) {
7
+ if (id === VIRTUAL_MODULE_ID) {
8
+ return RESOLVED_VIRTUAL_MODULE_ID;
9
+ }
10
+ },
11
+ load(id) {
12
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
13
+ return `export const config = ${JSON.stringify(config)};`;
14
+ }
15
+ },
16
+ };
17
+ }
18
+ //# sourceMappingURL=vite-plugin.js.map
@@ -0,0 +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"}
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@growth-labs/mailer",
3
+ "version": "0.1.3",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./components": {
11
+ "types": "./src/components/index.ts",
12
+ "import": "./src/components/index.ts"
13
+ },
14
+ "./utils": {
15
+ "types": "./dist/utils/index.d.ts",
16
+ "import": "./dist/utils/index.js"
17
+ },
18
+ "./utils/*": {
19
+ "types": "./dist/utils/*.d.ts",
20
+ "import": "./dist/utils/*.js"
21
+ },
22
+ "./middleware/*": {
23
+ "types": "./dist/middleware/*.d.ts",
24
+ "import": "./dist/middleware/*.js",
25
+ "default": "./dist/middleware/*.js"
26
+ },
27
+ "./routes/preferences": {
28
+ "types": "./src/routes/preferences.astro",
29
+ "import": "./src/routes/preferences.astro",
30
+ "default": "./src/routes/preferences.astro"
31
+ },
32
+ "./routes/*": {
33
+ "types": "./dist/routes/*.d.ts",
34
+ "import": "./dist/routes/*.js",
35
+ "default": "./dist/routes/*.js"
36
+ },
37
+ "./queue": {
38
+ "types": "./dist/queue/consumer.d.ts",
39
+ "import": "./dist/queue/consumer.js"
40
+ },
41
+ "./components/*": {
42
+ "types": "./src/components/*.astro",
43
+ "import": "./src/components/*.astro"
44
+ },
45
+ "./schema": {
46
+ "types": "./dist/schema.d.ts",
47
+ "import": "./dist/schema.js"
48
+ }
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "src",
53
+ "README.md"
54
+ ],
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "registry": "https://registry.npmjs.org/"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest"
63
+ },
64
+ "peerDependencies": {
65
+ "astro": "^6.0.0"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@growth-labs/analytics": {
69
+ "optional": true
70
+ }
71
+ },
72
+ "dependencies": {
73
+ "drizzle-orm": "^0.38.0",
74
+ "ulidx": "^2.4.0",
75
+ "zod": "^3.23.0"
76
+ },
77
+ "devDependencies": {
78
+ "@cloudflare/workers-types": "^4.20260402.1",
79
+ "astro": "^6.0.0",
80
+ "drizzle-kit": "^0.30.0",
81
+ "typescript": "^5.7.0",
82
+ "vite": "^7.0.0",
83
+ "vitest": "^3.0.0"
84
+ }
85
+ }
package/src/astro.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module '*.astro' {
2
+ const Component: any
3
+ export default Component
4
+ }
@@ -0,0 +1,147 @@
1
+ ---
2
+ import type { Subscriber } from "../types.js";
3
+
4
+ interface Props {
5
+ subscriber: Subscriber;
6
+ topics: string[];
7
+ token: string;
8
+ siteUrl: string;
9
+ subscribePath: string;
10
+ senderName: string;
11
+ brand?: {
12
+ primaryColor?: string;
13
+ accentColor?: string;
14
+ logoUrl?: string;
15
+ footerText?: string;
16
+ };
17
+ }
18
+
19
+ const {
20
+ subscriber,
21
+ topics,
22
+ token,
23
+ siteUrl,
24
+ subscribePath,
25
+ senderName,
26
+ brand,
27
+ } = Astro.props;
28
+
29
+ const primaryColor = brand?.primaryColor ?? "#1a365d";
30
+ const accentColor = brand?.accentColor ?? "#e53e3e";
31
+ const resubscribeUrl = new URL(subscribePath, siteUrl).toString();
32
+ ---
33
+
34
+ <div class="gl-preference-center" style="max-width: 600px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #111827;">
35
+ <div
36
+ class="gl-toast gl-toast-success"
37
+ data-toast="updated"
38
+ hidden
39
+ style={`background-color: #ecfdf5; border: 1px solid #6ee7b7; color: #065f46; padding: 12px 16px; border-radius: 8px; margin-bottom: 24px; font-size: 14px;`}
40
+ >
41
+ Your preferences have been updated.
42
+ </div>
43
+ <div
44
+ class="gl-toast gl-toast-info"
45
+ data-toast="unsubscribed"
46
+ hidden
47
+ style={`background-color: #eff6ff; border: 1px solid #93c5fd; color: #1e40af; padding: 12px 16px; border-radius: 8px; margin-bottom: 24px; font-size: 14px;`}
48
+ >
49
+ You have been unsubscribed from all emails.
50
+ </div>
51
+
52
+ <div style={`background-color: ${primaryColor}; padding: 24px; border-radius: 8px 8px 0 0; text-align: center;`}>
53
+ {brand?.logoUrl && (
54
+ <img
55
+ src={brand.logoUrl}
56
+ alt={senderName}
57
+ style="max-height: 48px; margin-bottom: 8px;"
58
+ />
59
+ )}
60
+ <h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">
61
+ Email Preferences
62
+ </h1>
63
+ </div>
64
+
65
+ <div style="background-color: #ffffff; padding: 32px 24px; border: 1px solid #e5e7eb; border-top: none;">
66
+ <p style="font-size: 16px; line-height: 24px; color: #374151; margin: 0 0 24px 0;">
67
+ Managing preferences for <strong>{subscriber.email}</strong>
68
+ </p>
69
+
70
+ {subscriber.status === "unsubscribed" ? (
71
+ <div style="text-align: center; padding: 24px 0;">
72
+ <p style="font-size: 16px; color: #374151; margin: 0 0 16px 0;">
73
+ You are currently unsubscribed from {senderName}.
74
+ </p>
75
+ <a
76
+ href={resubscribeUrl}
77
+ style={`display: inline-block; background-color: ${primaryColor}; color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 4px; font-weight: 700; font-size: 16px;`}
78
+ >
79
+ Re-subscribe
80
+ </a>
81
+ </div>
82
+ ) : (
83
+ <>
84
+ {topics.length > 0 && (
85
+ <form method="POST">
86
+ <input type="hidden" name="token" value={token} />
87
+ <input type="hidden" name="action" value="preferences" />
88
+
89
+ <fieldset style="border: none; padding: 0; margin: 0 0 24px 0;">
90
+ <legend style="font-size: 16px; font-weight: 700; color: #111827; margin-bottom: 12px;">
91
+ Choose the topics you'd like to receive:
92
+ </legend>
93
+ {topics.map((topic) => (
94
+ <label
95
+ style="display: flex; align-items: center; padding: 8px 12px; margin-bottom: 4px; border-radius: 4px; cursor: pointer; font-size: 15px; color: #374151;"
96
+ >
97
+ <input
98
+ type="checkbox"
99
+ name={`pref_${topic}`}
100
+ value="on"
101
+ checked={subscriber.preferences.includes(topic)}
102
+ style={`margin-right: 10px; width: 18px; height: 18px; accent-color: ${primaryColor};`}
103
+ />
104
+ {topic}
105
+ </label>
106
+ ))}
107
+ </fieldset>
108
+
109
+ <button
110
+ type="submit"
111
+ style={`display: inline-block; background-color: ${primaryColor}; color: #ffffff; border: none; padding: 12px 24px; border-radius: 4px; font-weight: 700; font-size: 16px; cursor: pointer;`}
112
+ >
113
+ Update preferences
114
+ </button>
115
+ </form>
116
+ )}
117
+
118
+ <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;" />
119
+
120
+ <form method="POST" style="text-align: center;">
121
+ <input type="hidden" name="token" value={token} />
122
+ <input type="hidden" name="action" value="unsubscribe" />
123
+ <p style="font-size: 14px; color: #6b7280; margin: 0 0 12px 0;">
124
+ Don't want to receive any more emails?
125
+ </p>
126
+ <button
127
+ type="submit"
128
+ style="display: inline-block; background-color: transparent; color: #6b7280; border: 1px solid #d1d5db; padding: 8px 20px; border-radius: 4px; font-size: 14px; cursor: pointer;"
129
+ >
130
+ Unsubscribe from all
131
+ </button>
132
+ </form>
133
+ </>
134
+ )}
135
+ </div>
136
+
137
+ <div style="padding: 16px; text-align: center;">
138
+ {brand?.footerText && (
139
+ <p style="font-size: 12px; color: #9ca3af; margin: 0 0 8px 0;">
140
+ {brand.footerText}
141
+ </p>
142
+ )}
143
+ <p style="font-size: 12px; color: #9ca3af; margin: 0;">
144
+ {senderName}
145
+ </p>
146
+ </div>
147
+ </div>
@@ -0,0 +1,161 @@
1
+ ---
2
+ import { config } from "virtual:growth-labs/mailer/config";
3
+
4
+ interface Props {
5
+ source?: string;
6
+ topics?: string[];
7
+ showNameField?: boolean;
8
+ showTopics?: boolean;
9
+ submitText?: string;
10
+ successMessage?: string;
11
+ class?: string;
12
+ }
13
+
14
+ const {
15
+ source = "form",
16
+ topics,
17
+ showNameField = false,
18
+ showTopics = false,
19
+ submitText = "Subscribe",
20
+ successMessage = "Check your email to confirm!",
21
+ class: className,
22
+ } = Astro.props;
23
+
24
+ const formTopics = showTopics ? (topics ?? config.topics ?? []) : [];
25
+ const formId = `gl-subscribe-${Math.random().toString(36).slice(2, 8)}`;
26
+ ---
27
+
28
+ <div class:list={['gl-subscribe-form', className]} id={formId}>
29
+ <form
30
+ method="POST"
31
+ action={config.subscribePath}
32
+ data-gl-subscribe-form
33
+ >
34
+ <div class="gl-subscribe-fields">
35
+ {showNameField && (
36
+ <div class="gl-subscribe-field">
37
+ <label for={`${formId}-name`}>Name</label>
38
+ <input
39
+ type="text"
40
+ id={`${formId}-name`}
41
+ name="name"
42
+ autocomplete="name"
43
+ />
44
+ </div>
45
+ )}
46
+ <div class="gl-subscribe-field">
47
+ <label for={`${formId}-email`}>Email</label>
48
+ <input
49
+ type="email"
50
+ id={`${formId}-email`}
51
+ name="email"
52
+ required
53
+ autocomplete="email"
54
+ placeholder="you@example.com"
55
+ />
56
+ </div>
57
+ </div>
58
+
59
+ {formTopics.length > 0 && (
60
+ <fieldset class="gl-subscribe-topics">
61
+ <legend>Topics</legend>
62
+ {formTopics.map((topic) => (
63
+ <label class="gl-subscribe-topic">
64
+ <input
65
+ type="checkbox"
66
+ name="preferences"
67
+ value={topic}
68
+ checked
69
+ />
70
+ {topic}
71
+ </label>
72
+ ))}
73
+ </fieldset>
74
+ )}
75
+
76
+ <input type="hidden" name="source" value={source} />
77
+ <div class="gl-turnstile" data-sitekey={config.turnstileSiteKey}></div>
78
+
79
+ <button type="submit" data-gl-submit>
80
+ {submitText}
81
+ </button>
82
+
83
+ <div class="gl-subscribe-message" data-gl-message hidden>
84
+ <p data-gl-success hidden>{successMessage}</p>
85
+ <p data-gl-error hidden></p>
86
+ </div>
87
+ </form>
88
+ </div>
89
+
90
+ <script define:vars={{ subscribePath: config.subscribePath, formId }}>
91
+ ;(function () {
92
+ const container = document.getElementById(formId)
93
+ if (!container) return
94
+
95
+ const form = container.querySelector('[data-gl-subscribe-form]')
96
+ const submitBtn = container.querySelector('[data-gl-submit]')
97
+ const messageEl = container.querySelector('[data-gl-message]')
98
+ const successEl = container.querySelector('[data-gl-success]')
99
+ const errorEl = container.querySelector('[data-gl-error]')
100
+
101
+ // Load Turnstile
102
+ if (!document.querySelector('script[src*="turnstile"]')) {
103
+ const script = document.createElement('script')
104
+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
105
+ script.async = true
106
+ document.head.appendChild(script)
107
+ }
108
+
109
+ form?.addEventListener('submit', async function (e) {
110
+ e.preventDefault()
111
+ if (!form || !submitBtn) return
112
+
113
+ submitBtn.setAttribute('disabled', 'true')
114
+ messageEl?.setAttribute('hidden', '')
115
+ successEl?.setAttribute('hidden', '')
116
+ errorEl?.setAttribute('hidden', '')
117
+
118
+ const formData = new FormData(form)
119
+ const preferences = formData.getAll('preferences')
120
+ const turnstileInput = form.querySelector(
121
+ '[name="cf-turnstile-response"]',
122
+ )
123
+ const turnstileToken = turnstileInput?.value ?? ''
124
+
125
+ try {
126
+ const res = await fetch(subscribePath, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json' },
129
+ body: JSON.stringify({
130
+ email: formData.get('email'),
131
+ name: formData.get('name') || undefined,
132
+ source: formData.get('source'),
133
+ preferences: preferences.length ? preferences : undefined,
134
+ turnstileToken,
135
+ }),
136
+ })
137
+
138
+ const data = await res.json()
139
+
140
+ messageEl?.removeAttribute('hidden')
141
+ if (data.success) {
142
+ successEl?.removeAttribute('hidden')
143
+ form.reset()
144
+ } else {
145
+ if (errorEl) {
146
+ errorEl.textContent = data.error || 'Something went wrong'
147
+ errorEl.removeAttribute('hidden')
148
+ }
149
+ }
150
+ } catch {
151
+ messageEl?.removeAttribute('hidden')
152
+ if (errorEl) {
153
+ errorEl.textContent = 'Network error. Please try again.'
154
+ errorEl.removeAttribute('hidden')
155
+ }
156
+ } finally {
157
+ submitBtn.removeAttribute('disabled')
158
+ }
159
+ })
160
+ })()
161
+ </script>
@@ -0,0 +1,2 @@
1
+ export { default as PreferenceCenter } from './PreferenceCenter.astro'
2
+ export { default as SubscribeForm } from './SubscribeForm.astro'
package/src/index.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { AstroIntegration } from 'astro'
2
+ import type { MailerOptions, ResolvedMailerOptions } from './options.js'
3
+ import { mailerOptionsSchema } from './options.js'
4
+ import { growthLabsMailerPlugin } from './vite-plugin.js'
5
+
6
+ export default function mailer(userOptions: MailerOptions): AstroIntegration {
7
+ const options = mailerOptionsSchema.parse(userOptions) as ResolvedMailerOptions
8
+
9
+ return {
10
+ name: '@growth-labs/mailer',
11
+ hooks: {
12
+ 'astro:config:setup': ({ injectRoute, addMiddleware, updateConfig, config: astroConfig }) => {
13
+ // Auto-detect @growth-labs/analytics
14
+ const hasAnalytics = astroConfig.integrations.some(
15
+ (i) => i.name === '@growth-labs/analytics',
16
+ )
17
+ if (hasAnalytics) {
18
+ ;(options as Record<string, unknown>).analyticsEnabled = true
19
+ }
20
+
21
+ // Install Vite plugin for virtual module
22
+ updateConfig({
23
+ vite: { plugins: [growthLabsMailerPlugin(options)] },
24
+ })
25
+
26
+ // Inject routes
27
+ injectRoute({
28
+ pattern: options.subscribePath,
29
+ entrypoint: '@growth-labs/mailer/routes/subscribe',
30
+ })
31
+ injectRoute({
32
+ pattern: options.confirmPath,
33
+ entrypoint: '@growth-labs/mailer/routes/confirm',
34
+ })
35
+ injectRoute({
36
+ pattern: options.unsubscribePath,
37
+ entrypoint: '@growth-labs/mailer/routes/unsubscribe',
38
+ })
39
+ injectRoute({
40
+ pattern: options.preferencesPath,
41
+ entrypoint: '@growth-labs/mailer/routes/preferences',
42
+ })
43
+ injectRoute({
44
+ pattern: options.webhookPath,
45
+ entrypoint: '@growth-labs/mailer/routes/webhook',
46
+ })
47
+ injectRoute({
48
+ pattern: `${options.trackOpenPath}/[trackingId]`,
49
+ entrypoint: '@growth-labs/mailer/routes/track-open',
50
+ })
51
+ injectRoute({
52
+ pattern: `${options.trackClickPath}/[trackingId]`,
53
+ entrypoint: '@growth-labs/mailer/routes/track-click',
54
+ })
55
+
56
+ // Add tracking middleware
57
+ addMiddleware({
58
+ entrypoint: '@growth-labs/mailer/middleware/tracking',
59
+ order: 'post',
60
+ })
61
+ },
62
+ },
63
+ }
64
+ }
65
+
66
+ export type { MailerOptions, ResolvedMailerOptions } from './options.js'
67
+ // Re-export options and types
68
+ export { mailerOptionsSchema } from './options.js'
69
+ export { handleEmailQueue } from './queue/consumer.js'
70
+ export type {
71
+ DigestItem,
72
+ EmailProvider,
73
+ EmailQueueMessage,
74
+ EmailSend,
75
+ EmailSendStatus,
76
+ OutboundEmail,
77
+ SendResult,
78
+ Subscriber,
79
+ SubscriberFilter,
80
+ SubscriberStatus,
81
+ TemplateData,
82
+ TemplateName,
83
+ } from './types.js'
84
+ export type { CampaignSchedule, DigestSchedule } from './utils/scheduling.js'
85
+ export {
86
+ executeCampaignSchedule,
87
+ executeDigestSchedule,
88
+ prepareCampaign,
89
+ prepareDigest,
90
+ sendBatchCampaigns,
91
+ } from './utils/scheduling.js'
92
+ export type { MailerEnv } from './utils/send.js'
93
+ // Re-export utilities for standalone use
94
+ export { sendCampaign, sendDigest, sendTransactional } from './utils/send.js'
95
+ export {
96
+ countSubscribers,
97
+ createSubscriber,
98
+ getSubscriberByEmail,
99
+ getSubscriberById,
100
+ } from './utils/subscribers.js'
101
+ export { renderEmail } from './utils/templates.js'
@@ -0,0 +1,18 @@
1
+ import { config } from 'virtual:growth-labs/mailer/config'
2
+ import type { MiddlewareHandler } from 'astro'
3
+
4
+ export const onRequest: MiddlewareHandler = async (_context, next) => {
5
+ const response = await next()
6
+ const url = new URL(_context.request.url)
7
+
8
+ if (
9
+ url.pathname.startsWith(config.trackOpenPath) ||
10
+ url.pathname.startsWith(config.trackClickPath)
11
+ ) {
12
+ response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate')
13
+ response.headers.delete('ETag')
14
+ response.headers.delete('Last-Modified')
15
+ }
16
+
17
+ return response
18
+ }
package/src/options.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod'
2
+
3
+ export const mailerOptionsSchema = z
4
+ .object({
5
+ // ─── Required ───
6
+ senderName: z.string(),
7
+ fromAddress: z.string().email(),
8
+
9
+ // ─── Reply-to ───
10
+ replyTo: z.string().email().optional(),
11
+
12
+ // ─── Cloudflare bindings ───
13
+ d1Binding: z.string().default('SITE_DB'),
14
+ queueBinding: z.string().default('EMAIL_QUEUE'),
15
+
16
+ // ─── Turnstile (subscribe form) ───
17
+ turnstileSiteKey: z.string(),
18
+ turnstileSecretKey: z.string(),
19
+
20
+ // ─── Subscriber lifecycle ───
21
+ doubleOptIn: z.boolean().default(true),
22
+ topics: z.array(z.string()).optional(),
23
+
24
+ // ─── Token signing ───
25
+ signingSecret: z.string(),
26
+
27
+ // ─── Routes ───
28
+ subscribePath: z.string().default('/api/newsletter/subscribe'),
29
+ confirmPath: z.string().default('/api/newsletter/confirm'),
30
+ unsubscribePath: z.string().default('/api/newsletter/unsubscribe'),
31
+ preferencesPath: z.string().default('/email/preferences'),
32
+ webhookPath: z.string().default('/api/email/webhook'),
33
+
34
+ // ─── Tracking ───
35
+ trackOpenPath: z.string().default('/api/email/open'),
36
+ trackClickPath: z.string().default('/api/email/click'),
37
+ siteUrl: z.string().url(),
38
+
39
+ // ─── Branding (for built-in templates) ───
40
+ brand: z
41
+ .object({
42
+ logoUrl: z.string().url().optional(),
43
+ primaryColor: z.string().default('#1a365d'),
44
+ accentColor: z.string().default('#e53e3e'),
45
+ footerText: z.string().optional(),
46
+ })
47
+ .default({}),
48
+
49
+ // ─── Fallback provider ───
50
+ fallbackProvider: z.enum(['resend', 'none']).default('none'),
51
+ resendApiKey: z.string().optional(),
52
+
53
+ // ─── Queue batching ───
54
+ batchSize: z.number().min(1).max(500).default(100),
55
+
56
+ // ─── Optional peer: analytics ───
57
+ analyticsEnabled: z.boolean().default(false),
58
+ })
59
+ .refine((data) => data.fallbackProvider !== 'resend' || data.resendApiKey, {
60
+ message: 'resendApiKey is required when fallbackProvider is "resend"',
61
+ path: ['resendApiKey'],
62
+ })
63
+
64
+ export type MailerOptions = z.input<typeof mailerOptionsSchema>
65
+ export type ResolvedMailerOptions = z.output<typeof mailerOptionsSchema>