@contentgrowth/content-emailing 0.1.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.
@@ -0,0 +1,98 @@
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
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * HTML Email Wrapper
3
+ * Full styled HTML template for wrapping email content
4
+ */
5
+
6
+ /**
7
+ * Wrap content HTML in email template with styling
8
+ * @param {string} contentHtml - The HTML content to wrap
9
+ * @param {string} subject - Email subject for the title
10
+ * @param {Object} data - Template data (portalUrl, unsubscribeUrl, etc.)
11
+ * @returns {string} Complete HTML email
12
+ */
13
+ export function wrapInEmailTemplate(contentHtml, subject, data = {}) {
14
+ const portalUrl = data.portalUrl || 'https://app.x0start.com';
15
+ const unsubscribeUrl = data.unsubscribeUrl || '{{unsubscribe_url}}';
16
+ const brandName = data.brandName || 'X0 Start';
17
+
18
+ return `
19
+ <!DOCTYPE html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="UTF-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
24
+ <title>${subject}</title>
25
+ <style>
26
+ body {
27
+ margin: 0;
28
+ padding: 0;
29
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
30
+ background-color: #f5f5f5;
31
+ line-height: 1.6;
32
+ }
33
+ .email-wrapper {
34
+ background-color: #f5f5f5;
35
+ padding: 40px 20px;
36
+ }
37
+ .email-container {
38
+ max-width: 600px;
39
+ margin: 0 auto;
40
+ background-color: #ffffff;
41
+ border-radius: 8px;
42
+ overflow: hidden;
43
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
44
+ }
45
+ .email-header {
46
+ padding: 40px 40px 20px;
47
+ text-align: center;
48
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
49
+ color: #ffffff;
50
+ }
51
+ .email-header h1 {
52
+ margin: 0;
53
+ font-size: 28px;
54
+ font-weight: 600;
55
+ }
56
+ .email-content {
57
+ padding: 30px 40px;
58
+ color: #333333;
59
+ }
60
+ .email-content h1 {
61
+ font-size: 24px;
62
+ margin-top: 0;
63
+ margin-bottom: 20px;
64
+ color: #333333;
65
+ }
66
+ .email-content h2 {
67
+ font-size: 20px;
68
+ margin-top: 30px;
69
+ margin-bottom: 15px;
70
+ color: #333333;
71
+ }
72
+ .email-content h3 {
73
+ font-size: 16px;
74
+ margin-top: 20px;
75
+ margin-bottom: 10px;
76
+ color: #333333;
77
+ }
78
+ .email-content p {
79
+ margin: 0 0 15px;
80
+ color: #666666;
81
+ }
82
+ .email-content a {
83
+ color: #667eea;
84
+ text-decoration: none;
85
+ }
86
+ .email-content ul, .email-content ol {
87
+ margin: 0 0 15px;
88
+ padding-left: 25px;
89
+ }
90
+ .email-content li {
91
+ margin-bottom: 8px;
92
+ color: #666666;
93
+ }
94
+ .email-content blockquote {
95
+ margin: 20px 0;
96
+ padding: 15px 20px;
97
+ background-color: #f8f9fa;
98
+ border-left: 4px solid #667eea;
99
+ color: #666666;
100
+ }
101
+ .email-content code {
102
+ padding: 2px 6px;
103
+ background-color: #f8f9fa;
104
+ border-radius: 3px;
105
+ font-family: 'Courier New', monospace;
106
+ font-size: 14px;
107
+ }
108
+ .email-content pre {
109
+ padding: 15px;
110
+ background-color: #f8f9fa;
111
+ border-radius: 6px;
112
+ overflow-x: auto;
113
+ }
114
+ .email-content pre code {
115
+ padding: 0;
116
+ background: none;
117
+ }
118
+ .btn {
119
+ display: inline-block;
120
+ padding: 12px 24px;
121
+ background-color: #667eea;
122
+ color: #ffffff !important;
123
+ text-decoration: none;
124
+ border-radius: 6px;
125
+ font-weight: 600;
126
+ margin: 10px 0;
127
+ }
128
+ .btn:hover {
129
+ background-color: #5568d3;
130
+ }
131
+ .email-footer {
132
+ padding: 20px 40px;
133
+ background-color: #f8f9fa;
134
+ text-align: center;
135
+ font-size: 12px;
136
+ color: #666666;
137
+ }
138
+ .email-footer a {
139
+ color: #667eea;
140
+ text-decoration: none;
141
+ }
142
+ hr {
143
+ border: none;
144
+ border-top: 1px solid #e0e0e0;
145
+ margin: 30px 0;
146
+ }
147
+ </style>
148
+ </head>
149
+ <body>
150
+ <div class="email-wrapper">
151
+ <div class="email-container">
152
+ <div class="email-content">
153
+ ${contentHtml}
154
+ </div>
155
+ <div class="email-footer">
156
+ <p style="margin: 0 0 10px;">
157
+ You're receiving this email from ${brandName}.
158
+ </p>
159
+ <p style="margin: 0;">
160
+ <a href="${unsubscribeUrl}">Unsubscribe</a> |
161
+ <a href="${portalUrl}/settings/notifications">Manage Preferences</a>
162
+ </p>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </body>
167
+ </html>
168
+ `.trim();
169
+ }
@@ -0,0 +1,11 @@
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';
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Email Utility Functions
3
+ * Common helpers for email processing
4
+ */
5
+
6
+ // Module-level cache for website URL
7
+ let cachedWebsiteUrl = null;
8
+
9
+ /**
10
+ * Build website URL based on environment configuration (with caching)
11
+ * @param {Object} env - Environment bindings
12
+ * @returns {string} Website URL (e.g., 'https://www.x0start.com')
13
+ */
14
+ export function getWebsiteUrl(env) {
15
+ if (cachedWebsiteUrl) {
16
+ return cachedWebsiteUrl;
17
+ }
18
+
19
+ const domain = env.DOMAIN || 'x0start.com';
20
+ const isDev = env.ENVIRONMENT === 'development' || !env.ENVIRONMENT;
21
+ const protocol = isDev ? 'http' : 'https';
22
+
23
+ cachedWebsiteUrl = `${protocol}://www.${domain}`;
24
+
25
+ return cachedWebsiteUrl;
26
+ }
27
+
28
+ /**
29
+ * Reset the cached website URL (for testing or config changes)
30
+ */
31
+ export function resetWebsiteUrlCache() {
32
+ cachedWebsiteUrl = null;
33
+ }
34
+
35
+ /**
36
+ * Encode all links in HTML to go through tracking redirect
37
+ * @param {string} html - HTML content with links
38
+ * @param {string} sendId - Unique send ID for tracking
39
+ * @param {Object} env - Environment bindings (optional, for auto-detecting URL)
40
+ * @param {string} websiteUrl - Base website URL (optional, overrides env detection)
41
+ * @returns {string} HTML with encoded tracking links
42
+ */
43
+ export function encodeTrackingLinks(html, sendId, env = null, websiteUrl = null) {
44
+ const baseUrl = websiteUrl || (env ? getWebsiteUrl(env) : 'https://www.x0start.com');
45
+
46
+ // Helper to decode HTML entities
47
+ const decodeHtmlEntities = (text) => {
48
+ return text
49
+ .replace(/&#x2F;/g, '/')
50
+ .replace(/&#x3A;/g, ':')
51
+ .replace(/&amp;/g, '&')
52
+ .replace(/&lt;/g, '<')
53
+ .replace(/&gt;/g, '>')
54
+ .replace(/&quot;/g, '"')
55
+ .replace(/&#39;/g, "'");
56
+ };
57
+
58
+ // Replace all href attributes with tracking URLs
59
+ return html.replace(/href="([^"]+)"/g, (match, url) => {
60
+ // Skip certain URLs that shouldn't be tracked
61
+ if (
62
+ url.startsWith('mailto:') ||
63
+ url.startsWith('tel:') ||
64
+ url.startsWith('#') ||
65
+ url.includes('{{') || // Skip template variables
66
+ url.includes('/email/unsubscribe/') ||
67
+ url.includes('/email/track/')
68
+ ) {
69
+ return match;
70
+ }
71
+
72
+ // Decode HTML entities first (marked encodes URLs with HTML entities)
73
+ const decodedUrl = decodeHtmlEntities(url);
74
+
75
+ // Encode the decoded URL in base64
76
+ const encodedUrl = Buffer.from(decodedUrl).toString('base64');
77
+
78
+ // Create tracking redirect URL
79
+ const trackingUrl = `${baseUrl}/r/${sendId}?url=${encodedUrl}`;
80
+
81
+ return `href="${trackingUrl}"`;
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Convert markdown to plain text
87
+ * @param {string} markdown - Markdown content
88
+ * @returns {string} Plain text version
89
+ */
90
+ export function markdownToPlainText(markdown) {
91
+ return markdown
92
+ .replace(/#{1,6}\s+/g, '') // Remove headers
93
+ .replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
94
+ .replace(/\*(.+?)\*/g, '$1') // Remove italic
95
+ .replace(/\[(.+?)\]\((.+?)\)/g, '$1: $2') // Keep both link text and URL
96
+ .replace(/`(.+?)`/g, '$1') // Remove code
97
+ .replace(/>\s+/g, '') // Remove blockquotes
98
+ .replace(/\n{3,}/g, '\n\n') // Normalize line breaks
99
+ .trim();
100
+ }
101
+
102
+ /**
103
+ * Extract variables from a template string
104
+ * @param {string} template - Template with {{variable}} placeholders
105
+ * @returns {string[]} Array of variable names found
106
+ */
107
+ export function extractVariables(template) {
108
+ const regex = /\{\{([^}]+)\}\}/g;
109
+ const variables = new Set();
110
+ let match;
111
+
112
+ while ((match = regex.exec(template)) !== null) {
113
+ variables.add(match[1].trim());
114
+ }
115
+
116
+ return Array.from(variables);
117
+ }
@@ -0,0 +1,117 @@
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
+ };
@@ -0,0 +1,117 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Plus, Edit2, Trash2, Mail, Loader } from 'lucide-react';
3
+ import { TemplateEditor } from './TemplateEditor';
4
+
5
+ export const TemplateManager = ({
6
+ apiClient,
7
+ title = 'Email Templates',
8
+ description = 'Manage your email templates'
9
+ }) => {
10
+ const [templates, setTemplates] = useState([]);
11
+ const [loading, setLoading] = useState(true);
12
+ const [showEditor, setShowEditor] = useState(false);
13
+ const [editingTemplate, setEditingTemplate] = useState(null);
14
+
15
+ useEffect(() => {
16
+ loadTemplates();
17
+ }, []);
18
+
19
+ const loadTemplates = async () => {
20
+ try {
21
+ setLoading(true);
22
+ const res = await apiClient.get('/templates');
23
+ if (res.data.success) {
24
+ setTemplates(res.data.templates);
25
+ }
26
+ } catch (err) {
27
+ console.error('Failed to load templates:', err);
28
+ } finally {
29
+ setLoading(false);
30
+ }
31
+ };
32
+
33
+ const handleSave = async (formData) => {
34
+ try {
35
+ await apiClient.post('/templates', formData);
36
+ setShowEditor(false);
37
+ loadTemplates();
38
+ } catch (err) {
39
+ console.error('Failed to save template:', err);
40
+ alert('Failed to save template');
41
+ }
42
+ };
43
+
44
+ const handleDelete = async (id) => {
45
+ if (!confirm('Delete this template?')) return;
46
+ try {
47
+ await apiClient.delete(`/templates/${id}`);
48
+ loadTemplates();
49
+ } catch (err) {
50
+ console.error('Failed to delete:', err);
51
+ alert('Failed to delete template');
52
+ }
53
+ };
54
+
55
+ return (
56
+ <div className="email-template-manager">
57
+ <div className="flex justify-between items-center mb-6">
58
+ <div>
59
+ <h1 className="text-2xl font-bold text-gray-900">{title}</h1>
60
+ <p className="text-gray-600 mt-1">{description}</p>
61
+ </div>
62
+ <button
63
+ onClick={() => { setEditingTemplate(null); setShowEditor(true); }}
64
+ className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
65
+ >
66
+ <Plus className="h-4 w-4" /> New Template
67
+ </button>
68
+ </div>
69
+
70
+ {loading ? (
71
+ <div className="flex justify-center p-12"><Loader className="animate-spin text-gray-500" /></div>
72
+ ) : (
73
+ <div className="grid gap-4">
74
+ {templates.map(t => (
75
+ <div key={t.template_id} className="bg-white border rounded-lg p-6 flex justify-between items-start hover:shadow-md transition">
76
+ <div>
77
+ <div className="flex items-center gap-3 mb-2">
78
+ <Mail className="h-5 w-5 text-blue-600" />
79
+ <h3 className="text-lg font-semibold">{t.template_name}</h3>
80
+ <span className="px-2 py-1 text-xs bg-gray-100 rounded">{t.template_type}</span>
81
+ </div>
82
+ <p className="text-sm text-gray-600">{t.description}</p>
83
+ </div>
84
+ <div className="flex gap-2">
85
+ <button
86
+ onClick={() => { setEditingTemplate(t); setShowEditor(true); }}
87
+ className="p-2 text-blue-600 hover:bg-blue-50 rounded"
88
+ >
89
+ <Edit2 className="h-4 w-4" />
90
+ </button>
91
+ <button
92
+ onClick={() => handleDelete(t.template_id)}
93
+ className="p-2 text-red-600 hover:bg-red-50 rounded"
94
+ >
95
+ <Trash2 className="h-4 w-4" />
96
+ </button>
97
+ </div>
98
+ </div>
99
+ ))}
100
+ {templates.length === 0 && (
101
+ <div className="text-center py-12 text-gray-500 bg-gray-50 rounded border-dashed border-2">
102
+ No templates found. Create one to get started.
103
+ </div>
104
+ )}
105
+ </div>
106
+ )}
107
+
108
+ {showEditor && (
109
+ <TemplateEditor
110
+ initialData={editingTemplate}
111
+ onSave={handleSave}
112
+ onCancel={() => setShowEditor(false)}
113
+ />
114
+ )}
115
+ </div>
116
+ );
117
+ };
package/src/index.js ADDED
@@ -0,0 +1,24 @@
1
+ // Backend services
2
+ export { EmailService } from './backend/EmailService.js';
3
+ export { EmailTemplateCacheDO, createDOCacheProvider } from './backend/EmailTemplateCacheDO.js';
4
+
5
+ // Routes
6
+ export {
7
+ createEmailRoutes,
8
+ createTemplateRoutes,
9
+ createTrackingRoutes
10
+ } from './backend/routes/index.js';
11
+
12
+ // Common utilities
13
+ export {
14
+ wrapInEmailTemplate,
15
+ getWebsiteUrl,
16
+ resetWebsiteUrlCache,
17
+ encodeTrackingLinks,
18
+ markdownToPlainText,
19
+ extractVariables
20
+ } from './common/index.js';
21
+
22
+ // Frontend components
23
+ export { TemplateManager } from './frontend/TemplateManager.jsx';
24
+ export { TemplateEditor } from './frontend/TemplateEditor.jsx';