@contentgrowth/content-emailing 0.4.1 → 0.6.0

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 (66) hide show
  1. package/dist/TemplateManager-Db41KyPN.d.cts +77 -0
  2. package/dist/TemplateManager-Db41KyPN.d.ts +77 -0
  3. package/dist/backend/EmailService.cjs +737 -0
  4. package/dist/backend/EmailService.cjs.map +1 -0
  5. package/dist/backend/EmailService.d.cts +101 -0
  6. package/dist/backend/EmailService.d.ts +101 -0
  7. package/dist/backend/EmailService.js +703 -0
  8. package/dist/backend/EmailService.js.map +1 -0
  9. package/dist/backend/EmailingCacheDO.cjs +389 -0
  10. package/dist/backend/EmailingCacheDO.cjs.map +1 -0
  11. package/dist/backend/EmailingCacheDO.d.cts +66 -0
  12. package/dist/backend/EmailingCacheDO.d.ts +66 -0
  13. package/dist/backend/EmailingCacheDO.js +364 -0
  14. package/dist/backend/EmailingCacheDO.js.map +1 -0
  15. package/dist/backend/routes/index.cjs +1001 -0
  16. package/dist/backend/routes/index.cjs.map +1 -0
  17. package/dist/backend/routes/index.d.cts +32 -0
  18. package/dist/backend/routes/index.d.ts +32 -0
  19. package/dist/backend/routes/index.js +965 -0
  20. package/dist/backend/routes/index.js.map +1 -0
  21. package/dist/cli.cjs +53 -0
  22. package/dist/cli.cjs.map +1 -0
  23. package/dist/cli.d.cts +1 -0
  24. package/dist/cli.d.ts +1 -0
  25. package/dist/cli.js +53 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/common/index.cjs +267 -0
  28. package/dist/common/index.cjs.map +1 -0
  29. package/dist/common/index.d.cts +46 -0
  30. package/dist/common/index.d.ts +46 -0
  31. package/{src/common/htmlWrapper.js → dist/common/index.js} +75 -18
  32. package/dist/common/index.js.map +1 -0
  33. package/dist/frontend/index.cjs +665 -0
  34. package/dist/frontend/index.cjs.map +1 -0
  35. package/dist/frontend/index.d.cts +32 -0
  36. package/dist/frontend/index.d.ts +32 -0
  37. package/dist/frontend/index.js +626 -0
  38. package/dist/frontend/index.js.map +1 -0
  39. package/dist/index.cjs +1842 -0
  40. package/dist/index.cjs.map +1 -0
  41. package/dist/index.d.cts +7 -0
  42. package/dist/index.d.ts +7 -0
  43. package/dist/index.js +1793 -0
  44. package/dist/index.js.map +1 -0
  45. package/package.json +31 -13
  46. package/examples/.env.example +0 -16
  47. package/examples/README.md +0 -55
  48. package/examples/mocks/MockD1.js +0 -311
  49. package/examples/mocks/MockEmailSender.js +0 -64
  50. package/examples/mocks/index.js +0 -5
  51. package/examples/package-lock.json +0 -73
  52. package/examples/package.json +0 -18
  53. package/examples/portal/index.html +0 -919
  54. package/examples/server.js +0 -314
  55. package/release.sh +0 -56
  56. package/src/backend/EmailService.js +0 -537
  57. package/src/backend/EmailingCacheDO.js +0 -466
  58. package/src/backend/routes/index.js +0 -30
  59. package/src/backend/routes/templates.js +0 -98
  60. package/src/backend/routes/tracking.js +0 -215
  61. package/src/backend/routes.js +0 -98
  62. package/src/common/index.js +0 -11
  63. package/src/common/utils.js +0 -141
  64. package/src/frontend/TemplateEditor.jsx +0 -117
  65. package/src/frontend/TemplateManager.jsx +0 -117
  66. package/src/index.js +0 -24
@@ -1,215 +0,0 @@
1
- /**
2
- * Email Tracking Routes Factory
3
- * Creates a Hono router with tracking endpoints for the email system.
4
- */
5
- import { Hono } from 'hono';
6
-
7
- // 1x1 transparent GIF pixel
8
- const TRACKING_PIXEL = new Uint8Array([
9
- 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00,
10
- 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x21,
11
- 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00,
12
- 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x01, 0x44,
13
- 0x00, 0x3B
14
- ]);
15
-
16
- /**
17
- * Create email tracking routes
18
- * @param {Object} env - Environment bindings
19
- * @param {Object} config - Configuration
20
- * @param {string} [config.emailTablePrefix='system_email_'] - Prefix for D1 tables
21
- * @returns {Hono} Hono router
22
- */
23
- export function createTrackingRoutes(env, config = {}) {
24
- const app = new Hono();
25
- const db = env.DB;
26
- const tablePrefix = config.emailTablePrefix || config.tableNamePrefix || 'system_email_';
27
-
28
- /**
29
- * GET /track/open/:token
30
- * Track email opens via a 1x1 pixel
31
- */
32
- app.get('/track/open/:token', async (c) => {
33
- const token = c.req.param('token');
34
-
35
- try {
36
- const sendId = token;
37
-
38
- // Look up the send record
39
- const send = await db
40
- .prepare(`SELECT * FROM ${tablePrefix}sends WHERE send_id = ?`)
41
- .bind(sendId)
42
- .first();
43
-
44
- if (send) {
45
- // Log the open event
46
- const eventId = crypto.randomUUID();
47
- const now = Math.floor(Date.now() / 1000);
48
-
49
- await db
50
- .prepare(
51
- `INSERT INTO ${tablePrefix}events (
52
- event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
53
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
54
- )
55
- .bind(
56
- eventId,
57
- sendId,
58
- send.user_id,
59
- send.tenant_id,
60
- send.email_kind,
61
- 'opened',
62
- JSON.stringify({
63
- user_agent: c.req.header('user-agent'),
64
- referer: c.req.header('referer'),
65
- }),
66
- now
67
- )
68
- .run();
69
- }
70
- } catch (error) {
71
- console.error('[EmailTracking] Error tracking email open:', error);
72
- // Don't fail the request - just serve the pixel
73
- }
74
-
75
- // Always return the tracking pixel
76
- return new Response(TRACKING_PIXEL, {
77
- headers: {
78
- 'Content-Type': 'image/gif',
79
- 'Cache-Control': 'no-cache, no-store, must-revalidate',
80
- 'Pragma': 'no-cache',
81
- 'Expires': '0',
82
- },
83
- });
84
- });
85
-
86
- /**
87
- * POST /track/click/:token
88
- * Track email clicks
89
- */
90
- app.post('/track/click/:token', async (c) => {
91
- const token = c.req.param('token');
92
-
93
- let url;
94
- try {
95
- const body = await c.req.json();
96
- url = body.url;
97
- } catch {
98
- return c.json({ success: false, error: 'Invalid request body' }, 400);
99
- }
100
-
101
- if (!url) {
102
- return c.json({ success: false, error: 'Missing URL' }, 400);
103
- }
104
-
105
- try {
106
- const sendId = token;
107
-
108
- // Look up the send record
109
- const send = await db
110
- .prepare(`SELECT * FROM ${tablePrefix}sends WHERE send_id = ?`)
111
- .bind(sendId)
112
- .first();
113
-
114
- if (send) {
115
- // Log the click event
116
- const eventId = crypto.randomUUID();
117
- const now = Math.floor(Date.now() / 1000);
118
-
119
- await db
120
- .prepare(
121
- `INSERT INTO ${tablePrefix}events (
122
- event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
123
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
124
- )
125
- .bind(
126
- eventId,
127
- sendId,
128
- send.user_id,
129
- send.tenant_id,
130
- send.email_kind,
131
- 'clicked',
132
- JSON.stringify({
133
- url: url,
134
- user_agent: c.req.header('user-agent'),
135
- referer: c.req.header('referer'),
136
- }),
137
- now
138
- )
139
- .run();
140
- }
141
-
142
- return c.json({ success: true, tracked: !!send });
143
- } catch (error) {
144
- console.error('[EmailTracking] Error tracking email click:', error);
145
- return c.json({ success: false, error: 'Failed to track click' }, 500);
146
- }
147
- });
148
-
149
- /**
150
- * GET /unsubscribe/:token
151
- * Unsubscribe a user from all email notifications
152
- */
153
- app.get('/unsubscribe/:token', async (c) => {
154
- const unsubToken = c.req.param('token');
155
-
156
- try {
157
- // Find the user by their unsubscribe token
158
- const prefs = await db
159
- .prepare(`SELECT * FROM ${tablePrefix}preferences WHERE unsub_token = ?`)
160
- .bind(unsubToken)
161
- .first();
162
-
163
- if (!prefs) {
164
- return c.json({ success: false, error: 'Invalid unsubscribe link' }, 404);
165
- }
166
-
167
- // Check if already unsubscribed
168
- const currentSettings = JSON.parse(prefs.email_settings || '{}');
169
- const alreadyUnsubscribed = Object.keys(currentSettings).length === 0;
170
-
171
- if (!alreadyUnsubscribed) {
172
- // Disable all email types by setting email_settings to empty object
173
- const now = Math.floor(Date.now() / 1000);
174
- await db
175
- .prepare(
176
- `UPDATE ${tablePrefix}preferences
177
- SET email_settings = '{}',
178
- updated_at = ?
179
- WHERE unsub_token = ?`
180
- )
181
- .bind(now, unsubToken)
182
- .run();
183
-
184
- // Log the unsubscribe event
185
- const eventId = crypto.randomUUID();
186
- await db
187
- .prepare(
188
- `INSERT INTO ${tablePrefix}events (
189
- event_id, send_id, user_id, tenant_id, email_kind, event_type, metadata, created_at
190
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
191
- )
192
- .bind(
193
- eventId,
194
- 'unsubscribe',
195
- prefs.user_id,
196
- prefs.tenant_id,
197
- 'all',
198
- 'unsubscribed',
199
- JSON.stringify({
200
- user_agent: c.req.header('user-agent'),
201
- }),
202
- now
203
- )
204
- .run();
205
- }
206
-
207
- return c.json({ success: true, alreadyUnsubscribed });
208
- } catch (error) {
209
- console.error('[EmailTracking] Error processing unsubscribe:', error);
210
- return c.json({ success: false, error: 'An error occurred' }, 500);
211
- }
212
- });
213
-
214
- return app;
215
- }
@@ -1,98 +0,0 @@
1
- /**
2
- * Email Routes Factory
3
- * Creates a Hono router with admin endpoints for the email system.
4
- */
5
- import { Hono } from 'hono';
6
- import { EmailService } from './EmailService';
7
-
8
- /**
9
- * Create email admin routes
10
- * @param {Object} env - Environment bindings
11
- * @param {Object} config - Configuration
12
- * @param {Object} cacheProvider - Optional cache provider
13
- * @returns {Hono} Hono router
14
- */
15
- export function createEmailRoutes(env, config = {}, cacheProvider = null) {
16
- const app = new Hono();
17
- const emailService = new EmailService(env, config, cacheProvider);
18
-
19
- // List all templates
20
- app.get('/templates', async (c) => {
21
- try {
22
- const templates = await emailService.getAllTemplates();
23
- return c.json({ success: true, templates });
24
- } catch (err) {
25
- return c.json({ success: false, error: err.message }, 500);
26
- }
27
- });
28
-
29
- // Get single template
30
- app.get('/templates/:id', async (c) => {
31
- try {
32
- const template = await emailService.getTemplate(c.req.param('id'));
33
- if (!template) return c.json({ success: false, error: 'Template not found' }, 404);
34
- return c.json({ success: true, template });
35
- } catch (err) {
36
- return c.json({ success: false, error: err.message }, 500);
37
- }
38
- });
39
-
40
- // Create/Update template
41
- app.post('/templates', async (c) => {
42
- try {
43
- const data = await c.req.json();
44
- await emailService.saveTemplate(data, 'admin'); // TODO: Pass real user ID
45
- return c.json({ success: true, message: 'Template saved' });
46
- } catch (err) {
47
- return c.json({ success: false, error: err.message }, 500);
48
- }
49
- });
50
-
51
- // Delete template
52
- app.delete('/templates/:id', async (c) => {
53
- try {
54
- await emailService.deleteTemplate(c.req.param('id'));
55
- return c.json({ success: true, message: 'Template deleted' });
56
- } catch (err) {
57
- return c.json({ success: false, error: err.message }, 500);
58
- }
59
- });
60
-
61
- // Preview template
62
- app.post('/templates/:id/preview', async (c) => {
63
- try {
64
- const id = c.req.param('id');
65
- const data = await c.req.json(); // Variables
66
- const result = await emailService.renderTemplate(id, data);
67
- return c.json({ success: true, preview: result });
68
- } catch (err) {
69
- return c.json({ success: false, error: err.message }, 500);
70
- }
71
- });
72
-
73
- // Send test email
74
- app.post('/templates/:id/test', async (c) => {
75
- try {
76
- const id = c.req.param('id');
77
- const { to, data } = await c.req.json();
78
-
79
- const { subject, html, plainText } = await emailService.renderTemplate(id, data);
80
- const result = await emailService.sendEmail({
81
- to,
82
- subject: `[TEST] ${subject}`,
83
- html,
84
- text: plainText
85
- });
86
-
87
- if (result.success) {
88
- return c.json({ success: true, message: 'Test email sent' });
89
- } else {
90
- return c.json({ success: false, error: result.error }, 500);
91
- }
92
- } catch (err) {
93
- return c.json({ success: false, error: err.message }, 500);
94
- }
95
- });
96
-
97
- return app;
98
- }
@@ -1,11 +0,0 @@
1
- /**
2
- * Common exports
3
- */
4
- export { wrapInEmailTemplate } from './htmlWrapper.js';
5
- export {
6
- getWebsiteUrl,
7
- resetWebsiteUrlCache,
8
- encodeTrackingLinks,
9
- markdownToPlainText,
10
- extractVariables
11
- } from './utils.js';
@@ -1,141 +0,0 @@
1
- /**
2
- * Email Utility Functions
3
- * Common helpers for email processing
4
- */
5
-
6
- import mustache from 'mustache';
7
-
8
- // Module-level cache for website URL
9
- let cachedWebsiteUrl = null;
10
-
11
- /**
12
- * Build website URL based on environment configuration (with caching)
13
- * @param {Object} env - Environment bindings
14
- * @returns {string} Website URL (e.g., 'https://www.x0start.com')
15
- */
16
- export function getWebsiteUrl(env) {
17
- if (cachedWebsiteUrl) {
18
- return cachedWebsiteUrl;
19
- }
20
-
21
- const domain = env.DOMAIN || 'x0start.com';
22
- const isDev = env.ENVIRONMENT === 'development' || !env.ENVIRONMENT;
23
- const protocol = isDev ? 'http' : 'https';
24
-
25
- cachedWebsiteUrl = `${protocol}://www.${domain}`;
26
-
27
- return cachedWebsiteUrl;
28
- }
29
-
30
- /**
31
- * Reset the cached website URL (for testing or config changes)
32
- */
33
- export function resetWebsiteUrlCache() {
34
- cachedWebsiteUrl = null;
35
- }
36
-
37
- /**
38
- * Encode all links in HTML to go through tracking redirect
39
- * @param {string} html - HTML content with links
40
- * @param {string} sendId - Unique send ID for tracking
41
- * @param {Object} env - Environment bindings (optional, for auto-detecting URL)
42
- * @param {string} websiteUrl - Base website URL (optional, overrides env detection)
43
- * @returns {string} HTML with encoded tracking links
44
- */
45
- export function encodeTrackingLinks(html, sendId, env = null, websiteUrl = null) {
46
- const baseUrl = websiteUrl || (env ? getWebsiteUrl(env) : 'https://www.x0start.com');
47
-
48
- // Helper to decode HTML entities
49
- const decodeHtmlEntities = (text) => {
50
- return text
51
- .replace(///g, '/')
52
- .replace(/:/g, ':')
53
- .replace(/&/g, '&')
54
- .replace(/&lt;/g, '<')
55
- .replace(/&gt;/g, '>')
56
- .replace(/&quot;/g, '"')
57
- .replace(/&#39;/g, "'");
58
- };
59
-
60
- // Replace all href attributes with tracking URLs
61
- return html.replace(/href="([^"]+)"/g, (match, url) => {
62
- // Skip certain URLs that shouldn't be tracked
63
- if (
64
- url.startsWith('mailto:') ||
65
- url.startsWith('tel:') ||
66
- url.startsWith('#') ||
67
- url.includes('{{') || // Skip template variables
68
- url.includes('/email/unsubscribe/') ||
69
- url.includes('/email/track/')
70
- ) {
71
- return match;
72
- }
73
-
74
- // Decode HTML entities first (marked encodes URLs with HTML entities)
75
- const decodedUrl = decodeHtmlEntities(url);
76
-
77
- // Encode the decoded URL in base64
78
- const encodedUrl = Buffer.from(decodedUrl).toString('base64');
79
-
80
- // Create tracking redirect URL
81
- const trackingUrl = `${baseUrl}/r/${sendId}?url=${encodedUrl}`;
82
-
83
- return `href="${trackingUrl}"`;
84
- });
85
- }
86
-
87
- /**
88
- * Convert markdown to plain text
89
- * @param {string} markdown - Markdown content
90
- * @returns {string} Plain text version
91
- */
92
- export function markdownToPlainText(markdown) {
93
- return markdown
94
- .replace(/#{1,6}\s+/g, '') // Remove headers
95
- .replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
96
- .replace(/\*(.+?)\*/g, '$1') // Remove italic
97
- .replace(/\[(.+?)\]\((.+?)\)/g, '$1: $2') // Keep both link text and URL
98
- .replace(/`(.+?)`/g, '$1') // Remove code
99
- .replace(/>\s+/g, '') // Remove blockquotes
100
- .replace(/\n{3,}/g, '\n\n') // Normalize line breaks
101
- .trim();
102
- }
103
-
104
- /**
105
- * Extract variables from a Mustache template string
106
- * @param {string} templateString - The mustache template string
107
- * @returns {string[]} Array of unique variable names
108
- */
109
- export function extractVariables(templateString) {
110
- if (!templateString) return [];
111
-
112
- try {
113
- // Parse the template to get tokens
114
- const tokens = mustache.parse(templateString);
115
- const variables = new Set();
116
-
117
- // Recursively extract variables from tokens
118
- const collectVariables = (tokenList) => {
119
- tokenList.forEach(token => {
120
- const type = token[0];
121
- const value = token[1];
122
-
123
- // Types: 'name' ({{var}}), '#' (section), '^' (inverted section), '&' (unescaped)
124
- if (type === 'name' || type === '#' || type === '^' || type === '&') {
125
- variables.add(value);
126
- }
127
-
128
- // Variable is in token[4] for sections
129
- if ((type === '#' || type === '^') && token[4]) {
130
- collectVariables(token[4]);
131
- }
132
- });
133
- };
134
-
135
- collectVariables(tokens);
136
- return Array.from(variables);
137
- } catch (error) {
138
- console.error('Error parsing template variables:', error);
139
- return [];
140
- }
141
- }
@@ -1,117 +0,0 @@
1
- import React, { useState, useEffect } from 'react';
2
- import { Save, X, Eye, AlertCircle } from 'lucide-react';
3
-
4
- export const TemplateEditor = ({
5
- initialData,
6
- onSave,
7
- onCancel,
8
- onPreview,
9
- variablesHelpText = 'Supports {{variable}}'
10
- }) => {
11
- const [formData, setFormData] = useState({
12
- template_id: '',
13
- template_name: '',
14
- template_type: 'daily_reminder',
15
- subject_template: '',
16
- body_markdown: '',
17
- variables: '[]',
18
- description: '',
19
- is_active: 1,
20
- ...initialData
21
- });
22
-
23
- const handleSubmit = (e) => {
24
- e.preventDefault();
25
- onSave(formData);
26
- };
27
-
28
- return (
29
- <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
30
- <div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
31
- <div className="p-6 border-b border-gray-200 flex justify-between items-center">
32
- <h2 className="text-xl font-bold text-gray-900">
33
- {initialData ? 'Edit Template' : 'New Template'}
34
- </h2>
35
- <button onClick={onCancel} className="p-2 hover:bg-gray-100 rounded-lg">
36
- <X className="h-5 w-5" />
37
- </button>
38
- </div>
39
-
40
- <form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-4">
41
- <div className="grid grid-cols-2 gap-4">
42
- <div>
43
- <label className="block text-sm font-medium text-gray-700 mb-1">Template ID *</label>
44
- <input
45
- type="text"
46
- required
47
- disabled={!!initialData}
48
- value={formData.template_id}
49
- onChange={e => setFormData({ ...formData, template_id: e.target.value })}
50
- className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
51
- />
52
- </div>
53
- <div>
54
- <label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
55
- <input
56
- type="text"
57
- required
58
- value={formData.template_name}
59
- onChange={e => setFormData({ ...formData, template_name: e.target.value })}
60
- className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
61
- />
62
- </div>
63
- </div>
64
-
65
- <div>
66
- <label className="block text-sm font-medium text-gray-700 mb-1">Subject *</label>
67
- <input
68
- type="text"
69
- required
70
- value={formData.subject_template}
71
- onChange={e => setFormData({ ...formData, subject_template: e.target.value })}
72
- className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
73
- />
74
- <p className="text-xs text-gray-500 mt-1">{variablesHelpText}</p>
75
- </div>
76
-
77
- <div>
78
- <label className="block text-sm font-medium text-gray-700 mb-1">Body (Markdown) *</label>
79
- <textarea
80
- required
81
- rows={12}
82
- value={formData.body_markdown}
83
- onChange={e => setFormData({ ...formData, body_markdown: e.target.value })}
84
- className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
85
- />
86
- </div>
87
-
88
- <div className="flex justify-between pt-4">
89
- <button
90
- type="button"
91
- onClick={() => onPreview && onPreview(formData)}
92
- className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
93
- >
94
- <Eye className="h-4 w-4" /> Preview
95
- </button>
96
-
97
- <div className="flex gap-2">
98
- <button
99
- type="button"
100
- onClick={onCancel}
101
- className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
102
- >
103
- Cancel
104
- </button>
105
- <button
106
- type="submit"
107
- className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
108
- >
109
- <Save className="h-4 w-4" /> Save
110
- </button>
111
- </div>
112
- </div>
113
- </form>
114
- </div>
115
- </div>
116
- );
117
- };