@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,314 @@
1
+ /**
2
+ * Example Email Server
3
+ *
4
+ * A standalone Hono server demonstrating the email package functionality.
5
+ * Uses mock D1 and email sender for testing without real dependencies.
6
+ *
7
+ * Run with: npm start
8
+ */
9
+
10
+ import 'dotenv/config'; // Load .env
11
+ import { Hono } from 'hono';
12
+ import { cors } from 'hono/cors';
13
+ import { serveStatic } from '@hono/node-server/serve-static';
14
+ import { serve } from '@hono/node-server';
15
+
16
+ import { EmailService } from '../src/backend/EmailService.js';
17
+ import { createTemplateRoutes } from '../src/backend/routes/templates.js';
18
+ import { createTrackingRoutes } from '../src/backend/routes/tracking.js';
19
+ import { wrapInEmailTemplate, encodeTrackingLinks, markdownToPlainText } from '../src/common/index.js';
20
+
21
+ import { createMockEnv, MockD1 } from './mocks/index.js';
22
+ import { mockEmailSender } from './mocks/MockEmailSender.js';
23
+
24
+ // Server Configuration
25
+ const PORT = process.env.PORT || 3456;
26
+ const USE_REAL_PROVIDER = process.env.EMAIL_PROVIDER && process.env.EMAIL_PROVIDER !== 'mock';
27
+
28
+ // Create mock environment
29
+ // Skip seeding settings if we have env vars, so we fallback to config defaults
30
+ const env = createMockEnv({ seedSettings: !USE_REAL_PROVIDER });
31
+
32
+ // Configuration from env
33
+ const emailConfig = {
34
+ defaults: {
35
+ fromName: process.env.FROM_NAME || 'Example App',
36
+ fromAddress: process.env.FROM_ADDRESS || 'noreply@example.com',
37
+ provider: process.env.EMAIL_PROVIDER || 'mock'
38
+ },
39
+ // Pass API keys directly to config (EmailService normalizes these)
40
+ sendgrid_api_key: process.env.SENDGRID_API_KEY,
41
+ resend_api_key: process.env.RESEND_API_KEY,
42
+ sendpulse_client_id: process.env.SENDPULSE_CLIENT_ID,
43
+ sendpulse_client_secret: process.env.SENDPULSE_CLIENT_SECRET
44
+ };
45
+
46
+ // Initialize EmailService
47
+ const emailService = new EmailService(env, emailConfig);
48
+
49
+ // Enhance sendEmail to support both real sending and mock logging
50
+ const originalSendEmail = emailService.sendEmail.bind(emailService);
51
+
52
+ emailService.sendEmail = async (params) => {
53
+ // 1. Get current settings to determine provider
54
+ const settings = await emailService.loadSettings('system');
55
+ const provider = params.provider || settings.provider || 'mock';
56
+
57
+ console.log('[Debug] sendEmail called');
58
+ console.log('[Debug] params.provider:', params.provider);
59
+ console.log('[Debug] settings.provider:', settings.provider);
60
+ console.log('[Debug] Resolved provider:', provider);
61
+ console.log('[Debug] Settings loaded:', JSON.stringify(settings, null, 2));
62
+
63
+ // 2. Always log to MockEmailSender for the UI
64
+ mockEmailSender.send({
65
+ to: params.to,
66
+ from: `${settings.fromName} <${settings.fromAddress}>`,
67
+ subject: params.subject,
68
+ html: params.html,
69
+ text: params.text,
70
+ status: provider === 'mock' ? 'sent' : 'sending' // UI distinction
71
+ });
72
+
73
+ // 3. If using a real provider, actually send it
74
+ if (provider !== 'mock') {
75
+ console.log(`[Server] Sending REAL email via ${provider}...`);
76
+ return originalSendEmail(params);
77
+ }
78
+
79
+ // 4. Otherwise just return success (MockEmailSender already logged it)
80
+ return { success: true, messageId: crypto.randomUUID() };
81
+ };
82
+
83
+ // Create Hono app
84
+ const app = new Hono();
85
+
86
+ // Enable CORS for portal
87
+ app.use('*', cors());
88
+
89
+ // Serve static files from portal directory
90
+ // When running from examples folder, root is ./
91
+ app.use('/portal/*', serveStatic({ root: './' }));
92
+
93
+ // Redirect root to portal
94
+ app.get('/', (c) => c.redirect('/portal/index.html'));
95
+
96
+ // Health check
97
+ app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
98
+
99
+ // Server status (for portal)
100
+ app.get('/api/status', (c) => {
101
+ return c.json({
102
+ success: true,
103
+ useRealProvider: USE_REAL_PROVIDER,
104
+ provider: emailConfig.defaults.provider,
105
+ fromAddress: emailConfig.defaults.fromAddress
106
+ });
107
+ });
108
+
109
+ // ============ Template API Routes ============
110
+
111
+ // List templates
112
+ app.get('/api/templates', async (c) => {
113
+ try {
114
+ const templates = await emailService.getAllTemplates();
115
+ return c.json({ success: true, templates });
116
+ } catch (err) {
117
+ return c.json({ success: false, error: err.message }, 500);
118
+ }
119
+ });
120
+
121
+ // Get single template
122
+ app.get('/api/templates/:id', async (c) => {
123
+ try {
124
+ const template = await emailService.getTemplate(c.req.param('id'));
125
+ if (!template) return c.json({ success: false, error: 'Template not found' }, 404);
126
+ return c.json({ success: true, template });
127
+ } catch (err) {
128
+ return c.json({ success: false, error: err.message }, 500);
129
+ }
130
+ });
131
+
132
+ // Create/Update template
133
+ app.post('/api/templates', async (c) => {
134
+ try {
135
+ const data = await c.req.json();
136
+ await emailService.saveTemplate(data, 'admin');
137
+ return c.json({ success: true, message: 'Template saved' });
138
+ } catch (err) {
139
+ return c.json({ success: false, error: err.message }, 500);
140
+ }
141
+ });
142
+
143
+ // Delete template
144
+ app.delete('/api/templates/:id', async (c) => {
145
+ try {
146
+ await emailService.deleteTemplate(c.req.param('id'));
147
+ return c.json({ success: true, message: 'Template deleted' });
148
+ } catch (err) {
149
+ return c.json({ success: false, error: err.message }, 500);
150
+ }
151
+ });
152
+
153
+ // Preview template (render with variables)
154
+ app.post('/api/templates/:id/preview', async (c) => {
155
+ try {
156
+ const id = c.req.param('id');
157
+ const variables = await c.req.json();
158
+ const result = await emailService.renderTemplate(id, variables);
159
+ return c.json({ success: true, preview: result });
160
+ } catch (err) {
161
+ return c.json({ success: false, error: err.message }, 500);
162
+ }
163
+ });
164
+
165
+ // Send test email
166
+ app.post('/api/templates/:id/test', async (c) => {
167
+ try {
168
+ const id = c.req.param('id');
169
+ const { to, variables } = await c.req.json();
170
+
171
+ if (!to) {
172
+ return c.json({ success: false, error: 'Recipient email required' }, 400);
173
+ }
174
+
175
+ const { subject, html, plainText } = await emailService.renderTemplate(id, variables || {});
176
+
177
+ // Add tracking (simulated)
178
+ const sendId = crypto.randomUUID();
179
+ const trackedHtml = encodeTrackingLinks(html, sendId, env);
180
+
181
+ const result = await emailService.sendEmail({
182
+ to,
183
+ subject: `[TEST] ${subject}`,
184
+ html: trackedHtml,
185
+ text: plainText
186
+ });
187
+
188
+ if (result.success) {
189
+ return c.json({ success: true, message: 'Test email sent', messageId: result.messageId });
190
+ } else {
191
+ return c.json({ success: false, error: result.error }, 500);
192
+ }
193
+ } catch (err) {
194
+ return c.json({ success: false, error: err.message }, 500);
195
+ }
196
+ });
197
+
198
+ // ============ Sent Emails API (for demo) ============
199
+
200
+ // Get all "sent" emails (from mock)
201
+ app.get('/api/sent-emails', (c) => {
202
+ return c.json({ success: true, emails: mockEmailSender.getSentEmails() });
203
+ });
204
+
205
+ // Clear sent emails
206
+ app.delete('/api/sent-emails', (c) => {
207
+ mockEmailSender.clear();
208
+ return c.json({ success: true, message: 'Cleared' });
209
+ });
210
+
211
+ // ============ Tracking Routes (demo) ============
212
+
213
+ // Open tracking pixel
214
+ app.get('/email/track/open/:token', async (c) => {
215
+ const token = c.req.param('token');
216
+ console.log(`[Tracking] Email opened: ${token}`);
217
+
218
+ // Return 1x1 transparent GIF
219
+ const pixel = new Uint8Array([
220
+ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00,
221
+ 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x21,
222
+ 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00,
223
+ 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x01, 0x44,
224
+ 0x00, 0x3B
225
+ ]);
226
+
227
+ return new Response(pixel, {
228
+ headers: {
229
+ 'Content-Type': 'image/gif',
230
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
231
+ }
232
+ });
233
+ });
234
+
235
+ // Click tracking
236
+ app.get('/r/:token', (c) => {
237
+ const token = c.req.param('token');
238
+ const encodedUrl = c.req.query('url');
239
+
240
+ if (!encodedUrl) {
241
+ return c.json({ error: 'Missing URL' }, 400);
242
+ }
243
+
244
+ try {
245
+ const url = Buffer.from(encodedUrl, 'base64').toString('utf-8');
246
+ console.log(`[Tracking] Link clicked: ${token} -> ${url}`);
247
+ return c.redirect(url);
248
+ } catch {
249
+ return c.json({ error: 'Invalid URL' }, 400);
250
+ }
251
+ });
252
+
253
+ // ============ Settings API (for demo) ============
254
+
255
+ // Get system settings
256
+ app.get('/api/settings', async (c) => {
257
+ try {
258
+ const result = await env.DB.prepare('SELECT * FROM system_settings').all();
259
+ // Convert array of {setting_key, setting_value} to object
260
+ const settings = {};
261
+ (result.results || []).forEach(row => {
262
+ settings[row.setting_key] = row.setting_value;
263
+ });
264
+ return c.json({ success: true, settings });
265
+ } catch (err) {
266
+ return c.json({ success: false, error: err.message }, 500);
267
+ }
268
+ });
269
+
270
+ // Update system settings
271
+ app.post('/api/settings', async (c) => {
272
+ try {
273
+ const settings = await c.req.json();
274
+
275
+ // Update each setting
276
+ const stmts = [];
277
+ for (const [key, value] of Object.entries(settings)) {
278
+ // Check if exists
279
+ const exists = await env.DB.prepare('SELECT 1 FROM system_settings WHERE setting_key = ?').bind(key).first();
280
+
281
+ if (exists) {
282
+ stmts.push(env.DB.prepare('UPDATE system_settings SET setting_value = ? WHERE setting_key = ?').bind(value, key));
283
+ } else {
284
+ stmts.push(env.DB.prepare('INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?)').bind(key, value));
285
+ }
286
+ }
287
+
288
+ await env.DB.batch(stmts);
289
+ return c.json({ success: true, message: 'Settings updated' });
290
+ } catch (err) {
291
+ return c.json({ success: false, error: err.message }, 500);
292
+ }
293
+ });
294
+
295
+ // ============ Start Server ============
296
+
297
+ console.log(`
298
+ ╔════════════════════════════════════════════════════════════╗
299
+ ā•‘ Email Package Example Server ā•‘
300
+ ╠════════════════════════════════════════════════════════════╣
301
+ ā•‘ ā•‘
302
+ ā•‘ Portal: http://localhost:${PORT}/portal/ ā•‘
303
+ ā•‘ API: http://localhost:${PORT}/api/templates ā•‘
304
+ ā•‘ Health: http://localhost:${PORT}/health ā•‘
305
+ ā•‘ ā•‘
306
+ ā•‘ Provider: ${USE_REAL_PROVIDER ? emailConfig.defaults.provider.toUpperCase() + ' (REAL)' : 'MOCK (No emails sent)'} ā•‘
307
+ ā•‘ ā•‘
308
+ ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•
309
+ `);
310
+
311
+ serve({
312
+ fetch: app.fetch,
313
+ port: Number(PORT)
314
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@contentgrowth/content-emailing",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Unified email delivery and template management system",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./backend": "./src/backend/EmailService.js",
10
+ "./routes": "./src/backend/routes/index.js",
11
+ "./common": "./src/common/index.js"
12
+ },
13
+ "scripts": {
14
+ "test": "echo \"Error: no test specified\" && exit 1",
15
+ "prepublishOnly": "echo '\nšŸ“¦ Package contents:\n' && npm pack --dry-run && echo '\nāš ļø Review the files above before publishing!\n'"
16
+ },
17
+ "author": "Content Growth",
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "marked": "^9.1.2",
21
+ "mustache": "^4.2.0",
22
+ "hono": "^4.0.0"
23
+ },
24
+ "peerDependencies": {
25
+ "react": "^18.2.0",
26
+ "lucide-react": "^0.292.0",
27
+ "tailwindcss": "^3.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "eslint": "^8.0.0"
31
+ }
32
+ }
package/release.sh ADDED
@@ -0,0 +1,56 @@
1
+ #!/bin/bash
2
+
3
+ # Exit immediately if a command exits with a non-zero status
4
+ set -e
5
+
6
+ echo "šŸš€ Preparing to release @contentgrowth/content-emailing ..."
7
+ echo ""
8
+
9
+ # Check for uncommitted changes
10
+ if ! git diff-index --quiet HEAD --; then
11
+ echo "āš ļø WARNING: You have uncommitted changes!"
12
+ git status --short
13
+ echo ""
14
+ read -p "Continue anyway? (y/N) " -n 1 -r
15
+ echo ""
16
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
17
+ echo "āŒ Release cancelled."
18
+ exit 1
19
+ fi
20
+ fi
21
+
22
+ # Show what will be published
23
+ echo "šŸ“¦ Package contents preview:"
24
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
25
+ npm pack --dry-run
26
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
27
+ echo ""
28
+
29
+ # Security check: Verify no .env files are included
30
+ if npm pack --dry-run 2>&1 | grep -q "\<\.env\>"; then
31
+ echo "🚨 SECURITY ERROR: .env file detected in package!"
32
+ echo "āŒ Release cancelled for security reasons."
33
+ exit 1
34
+ fi
35
+
36
+ # Confirm before publishing
37
+ read -p "Proceed with publishing? (y/N) " -n 1 -r
38
+ echo ""
39
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
40
+ echo "āŒ Release cancelled."
41
+ exit 1
42
+ fi
43
+
44
+ echo ""
45
+ echo "šŸ“¤ Publishing to npm..."
46
+
47
+ # Note: No build step required as this is a pure ES module project
48
+ # If a build step is added later (e.g. TypeScript), add 'npm run build' here
49
+
50
+ # Publish to npm
51
+ # --access public is required for scoped packages to be public
52
+ npm publish --access public
53
+
54
+ echo ""
55
+ echo "āœ… Release complete!"
56
+ echo "šŸ“¦ Package published: @contentgrowth/content-emailing@$(node -p "require('./package.json').version")"
package/schema.sql ADDED
@@ -0,0 +1,63 @@
1
+ -- Email System Schema
2
+
3
+ -- System email templates table
4
+ CREATE TABLE IF NOT EXISTS system_email_templates (
5
+ template_id TEXT PRIMARY KEY,
6
+ template_name TEXT NOT NULL,
7
+ template_type TEXT NOT NULL,
8
+ subject_template TEXT NOT NULL,
9
+ body_markdown TEXT NOT NULL,
10
+ variables TEXT, -- JSON array
11
+ description TEXT,
12
+ is_active INTEGER NOT NULL DEFAULT 1,
13
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
14
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
15
+ updated_by TEXT
16
+ );
17
+
18
+ -- System email preferences table
19
+ CREATE TABLE IF NOT EXISTS system_email_preferences (
20
+ user_id TEXT NOT NULL,
21
+ tenant_id TEXT NOT NULL,
22
+ timezone TEXT NOT NULL DEFAULT 'UTC',
23
+ email_settings TEXT NOT NULL DEFAULT '{}',
24
+ unsub_token TEXT NOT NULL UNIQUE,
25
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
26
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
27
+ PRIMARY KEY (user_id, tenant_id)
28
+ );
29
+
30
+ -- System email sends table
31
+ CREATE TABLE IF NOT EXISTS system_email_sends (
32
+ send_id TEXT PRIMARY KEY,
33
+ user_id TEXT NOT NULL,
34
+ tenant_id TEXT NOT NULL,
35
+ email_kind TEXT NOT NULL,
36
+ period_key TEXT NOT NULL,
37
+ status TEXT NOT NULL DEFAULT 'sent',
38
+ provider_message_id TEXT,
39
+ error_message TEXT,
40
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
41
+ UNIQUE(user_id, email_kind, period_key)
42
+ );
43
+
44
+ -- System email events table
45
+ CREATE TABLE IF NOT EXISTS system_email_events (
46
+ event_id TEXT PRIMARY KEY,
47
+ send_id TEXT NOT NULL,
48
+ user_id TEXT NOT NULL,
49
+ tenant_id TEXT NOT NULL,
50
+ email_kind TEXT NOT NULL,
51
+ event_type TEXT NOT NULL,
52
+ metadata TEXT,
53
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
54
+ FOREIGN KEY (send_id) REFERENCES system_email_sends(send_id) ON DELETE CASCADE
55
+ );
56
+
57
+ -- Indexes
58
+ CREATE INDEX IF NOT EXISTS idx_system_email_templates_type ON system_email_templates(template_type, is_active);
59
+ CREATE INDEX IF NOT EXISTS idx_system_email_preferences_tenant ON system_email_preferences(tenant_id);
60
+ CREATE INDEX IF NOT EXISTS idx_system_email_preferences_unsub_token ON system_email_preferences(unsub_token);
61
+ CREATE INDEX IF NOT EXISTS idx_system_email_sends_user ON system_email_sends(user_id);
62
+ CREATE INDEX IF NOT EXISTS idx_system_email_sends_period ON system_email_sends(email_kind, period_key);
63
+ CREATE INDEX IF NOT EXISTS idx_system_email_events_send ON system_email_events(send_id);