@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.
- package/README.md +96 -0
- package/examples/.env.example +16 -0
- package/examples/README.md +55 -0
- package/examples/mocks/MockD1.js +311 -0
- package/examples/mocks/MockEmailSender.js +64 -0
- package/examples/mocks/index.js +5 -0
- package/examples/package-lock.json +73 -0
- package/examples/package.json +18 -0
- package/examples/portal/index.html +919 -0
- package/examples/server.js +314 -0
- package/package.json +32 -0
- package/release.sh +56 -0
- package/schema.sql +63 -0
- package/src/backend/EmailService.js +474 -0
- package/src/backend/EmailTemplateCacheDO.js +363 -0
- package/src/backend/routes/index.js +30 -0
- package/src/backend/routes/templates.js +98 -0
- package/src/backend/routes/tracking.js +215 -0
- package/src/backend/routes.js +98 -0
- package/src/common/htmlWrapper.js +169 -0
- package/src/common/index.js +11 -0
- package/src/common/utils.js +117 -0
- package/src/frontend/TemplateEditor.jsx +117 -0
- package/src/frontend/TemplateManager.jsx +117 -0
- package/src/index.js +24 -0
|
@@ -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,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(///g, '/')
|
|
50
|
+
.replace(/:/g, ':')
|
|
51
|
+
.replace(/&/g, '&')
|
|
52
|
+
.replace(/</g, '<')
|
|
53
|
+
.replace(/>/g, '>')
|
|
54
|
+
.replace(/"/g, '"')
|
|
55
|
+
.replace(/'/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';
|