@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 ADDED
@@ -0,0 +1,96 @@
1
+ # @emails/backend
2
+
3
+ A comprehensive email management and delivery package designed for **Cloudflare Workers**.
4
+
5
+ This package provides a complete solution for handling transactional emails, managing templates (with Markdown and variable substitution), and tracking email events (opens, clicks). It relies on **Cloudflare D1** for storage and supports optional **Durable Objects** for high-performance caching.
6
+
7
+ ## Features
8
+
9
+ - **Multi-Provider Support**: Switch between MailChannels, SendGrid, Resend, SendPulse, or Mock (for testing) via configuration.
10
+ - **Template Management**: Create and edit email templates using Markdown.
11
+ - **Dynamic Variables**: Mustache-style variable substitution (`{{ user_name }}`) in subjects and bodies.
12
+ - **Email Tracking**: Built-in support for tracking email opens (pixel) and link clicks.
13
+ - **Cloudflare D1**: Stores templates, settings, and logs in D1 SQL database.
14
+ - **Durable Objects Cache**: Optional caching layer for high-speed template retrieval.
15
+ - **React Components**: Ready-to-use UI components for managing templates (`TemplateManager`, `TemplateEditor`).
16
+
17
+ ## Requirements
18
+
19
+ - **Runtime**: Cloudflare Workers (or compatible environment like Hono on Node.js with D1 bindings).
20
+ - **Framework**: built with [Hono](https://hono.dev).
21
+ - **Database**: Cloudflare D1.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ npm install @emails/backend
27
+ ```
28
+
29
+ ## Setup & Usage
30
+
31
+ ### 1. Initialize Email Service
32
+
33
+ In your worker or Hono app:
34
+
35
+ ```javascript
36
+ import { EmailService } from '@emails/backend';
37
+
38
+ // Initialize with your environment (needs DB binding)
39
+ const emailService = new EmailService(env, {
40
+ defaults: {
41
+ fromName: 'My App',
42
+ fromAddress: 'noreply@myapp.com',
43
+ provider: 'resend' // or 'mailchannels', 'sendgrid', 'mock'
44
+ },
45
+ // API keys from env
46
+ resend_api_key: env.RESEND_API_KEY
47
+ });
48
+ ```
49
+
50
+ ### 2. Database Schema
51
+
52
+ Ensure your D1 database has the required tables. See `examples/mocks/MockD1.js` for the schema structure, or refer to the provided SQL migration files (if available).
53
+
54
+ Tables needed:
55
+ - `system_email_templates`
56
+ - `system_settings`
57
+ - `system_email_sends` (for logs)
58
+
59
+ ### 3. Add API Routes
60
+
61
+ Mount the management routes in your Hono app:
62
+
63
+ ```javascript
64
+ import { createTemplateRoutes, createTrackingRoutes } from '@emails/backend';
65
+
66
+ // Template management (protected routes recommended)
67
+ app.route('/api/templates', createTemplateRoutes(emailService));
68
+
69
+ // Public tracking routes (opens/clicks)
70
+ app.route('/email', createTrackingRoutes(emailService));
71
+ ```
72
+
73
+ ## Running the Example
74
+
75
+ A fully functional example server is included in `examples/`. It uses a **Mock D1** implementation so you can run it locally without deploying to Cloudflare.
76
+
77
+ ```bash
78
+ cd examples
79
+ npm install
80
+ npm start
81
+ ```
82
+
83
+ Visit `http://localhost:3456/portal/` to try the Template Manager UI.
84
+
85
+ ## Environment Variables
86
+
87
+ Configure your provider using standard environment variables or pass them in the config object:
88
+
89
+ - `EMAIL_PROVIDER`: `resend`, `sendgrid`, `mailchannels`, `sendpulse`, or `mock`
90
+ - `RESEND_API_KEY`
91
+ - `SENDGRID_API_KEY`
92
+ - `SENDPULSE_CLIENT_ID` / `SENDPULSE_CLIENT_SECRET`
93
+
94
+ ## Architecture Note
95
+
96
+ This package is "Cloudflare-native". It expects `env.DB` to be a D1 binding. If running outside Cloudflare (e.g. Node.js), you must provide a D1-compatible mock or adapter, as demonstrated in the `examples/` folder.
@@ -0,0 +1,16 @@
1
+ # Server Configuration
2
+ PORT=3456
3
+
4
+ # Email Configuration
5
+ # Options: mock, mailchannels, sendgrid, resend, sendpulse
6
+ EMAIL_PROVIDER=mock
7
+
8
+ # Provider Credentials (if using real provider)
9
+ SENDGRID_API_KEY=
10
+ RESEND_API_KEY=
11
+ SENDPULSE_CLIENT_ID=
12
+ SENDPULSE_CLIENT_SECRET=
13
+
14
+ # Sender Info
15
+ FROM_NAME="Example App"
16
+ FROM_ADDRESS="noreply@example.com"
@@ -0,0 +1,55 @@
1
+ # Email Package Examples
2
+
3
+ This folder contains a working example server and portal UI for testing the email package.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ cd examples
9
+ npm install
10
+ npm start
11
+ ```
12
+
13
+ Then open http://localhost:3456/portal/ in your browser.
14
+
15
+ ## What's Included
16
+
17
+ ### Mocks (`mocks/`)
18
+
19
+ - **MockD1.js** - In-memory D1 database implementation with sample templates
20
+ - **MockEmailSender.js** - Simulates email sending (logs to console, stores in memory)
21
+
22
+ ### Server (`server.js`)
23
+
24
+ A Hono server that exposes:
25
+ - `GET /api/templates` - List all templates
26
+ - `GET /api/templates/:id` - Get single template
27
+ - `POST /api/templates` - Create/update template
28
+ - `DELETE /api/templates/:id` - Delete template
29
+ - `POST /api/templates/:id/preview` - Render template with variables
30
+ - `POST /api/templates/:id/test` - Send test email (mocked)
31
+ - `GET /api/sent-emails` - View "sent" emails
32
+ - `GET /email/track/open/:token` - Open tracking pixel
33
+ - `GET /r/:token` - Click tracking redirect
34
+
35
+ ### Portal (`portal/`)
36
+
37
+ A standalone HTML portal for:
38
+ - Browsing and editing templates
39
+ - Live preview with variable substitution
40
+ - Sending test emails
41
+ - Viewing sent email history
42
+
43
+ ## No Real Dependencies
44
+
45
+ This example uses **no real external services**:
46
+ - D1 is mocked with in-memory storage
47
+ - Emails are logged to console, not actually sent
48
+ - All data is ephemeral (resets on server restart)
49
+
50
+ ## Sample Templates
51
+
52
+ The mock D1 comes pre-loaded with sample templates:
53
+ - `welcome` - Onboarding welcome email
54
+ - `password_reset` - Password reset request
55
+ - `weekly_digest` - Weekly activity summary
@@ -0,0 +1,311 @@
1
+ /**
2
+ * MockD1 - In-memory D1 database mock for testing
3
+ *
4
+ * This provides a simple in-memory implementation of the D1 interface
5
+ * for testing the email package without a real database.
6
+ */
7
+
8
+ export class MockD1 {
9
+ constructor(options = {}) {
10
+ this.seedSettings = options.seedSettings !== false;
11
+
12
+ // In-memory storage
13
+ this.tables = {
14
+ system_email_templates: [],
15
+ system_email_preferences: [],
16
+ system_email_sends: [],
17
+ system_email_events: [],
18
+ system_settings: [],
19
+ tenants: []
20
+ };
21
+
22
+ // Seed with sample data
23
+ this._seedData();
24
+ }
25
+
26
+ _seedData() {
27
+ // Add sample templates
28
+ this.tables.system_email_templates = [
29
+ {
30
+ template_id: 'welcome',
31
+ template_name: 'Welcome Email',
32
+ template_type: 'onboarding',
33
+ subject_template: 'Welcome to {{company_name}}, {{user_name}}!',
34
+ body_markdown: `# Welcome, {{user_name}}!
35
+
36
+ We're thrilled to have you join **{{company_name}}**!
37
+
38
+ ## Getting Started
39
+
40
+ Here are a few things you can do:
41
+
42
+ 1. **Complete your profile** - Add your photo and bio
43
+ 2. **Explore features** - Check out our [documentation]({{docs_url}})
44
+ 3. **Join the community** - Connect with other users
45
+
46
+ If you have any questions, just reply to this email.
47
+
48
+ Best regards,
49
+ The {{company_name}} Team`,
50
+ variables: JSON.stringify(['user_name', 'company_name', 'docs_url']),
51
+ description: 'Sent to new users after signup',
52
+ is_active: 1,
53
+ created_at: Math.floor(Date.now() / 1000),
54
+ updated_at: Math.floor(Date.now() / 1000),
55
+ updated_by: 'system'
56
+ },
57
+ {
58
+ template_id: 'password_reset',
59
+ template_name: 'Password Reset',
60
+ template_type: 'transactional',
61
+ subject_template: 'Reset your password for {{company_name}}',
62
+ body_markdown: `# Password Reset Request
63
+
64
+ Hi {{user_name}},
65
+
66
+ We received a request to reset your password. Click the button below to create a new password:
67
+
68
+ [Reset Password]({{reset_url}})
69
+
70
+ This link will expire in **1 hour**.
71
+
72
+ If you didn't request this, you can safely ignore this email.
73
+
74
+ ---
75
+ *This is an automated message from {{company_name}}*`,
76
+ variables: JSON.stringify(['user_name', 'company_name', 'reset_url']),
77
+ description: 'Sent when user requests password reset',
78
+ is_active: 1,
79
+ created_at: Math.floor(Date.now() / 1000),
80
+ updated_at: Math.floor(Date.now() / 1000),
81
+ updated_by: 'system'
82
+ },
83
+ {
84
+ template_id: 'weekly_digest',
85
+ template_name: 'Weekly Digest',
86
+ template_type: 'marketing',
87
+ subject_template: 'Your weekly update from {{company_name}}',
88
+ body_markdown: `# Weekly Digest
89
+
90
+ Hi {{user_name}},
91
+
92
+ Here's what happened this week:
93
+
94
+ {{#highlights}}
95
+ - {{.}}
96
+ {{/highlights}}
97
+
98
+ ## Stats
99
+
100
+ | Metric | Value |
101
+ |--------|-------|
102
+ | Views | {{views}} |
103
+ | Clicks | {{clicks}} |
104
+
105
+ [View Full Report]({{report_url}})
106
+
107
+ See you next week!`,
108
+ variables: JSON.stringify(['user_name', 'company_name', 'highlights', 'views', 'clicks', 'report_url']),
109
+ description: 'Weekly activity summary',
110
+ is_active: 1,
111
+ created_at: Math.floor(Date.now() / 1000),
112
+ updated_at: Math.floor(Date.now() / 1000),
113
+ updated_by: 'system'
114
+ }
115
+ ];
116
+
117
+ // Add sample settings only if seedSettings is true
118
+ if (this.seedSettings) {
119
+ const env = (typeof process !== 'undefined' && process.env) || {};
120
+
121
+ this.tables.system_settings = [
122
+ { setting_key: 'email_provider', setting_value: env.EMAIL_PROVIDER || 'mock' },
123
+ { setting_key: 'email_from_address', setting_value: env.FROM_ADDRESS || 'noreply@example.com' },
124
+ { setting_key: 'email_from_name', setting_value: env.FROM_NAME || 'Example App' }
125
+ ];
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Prepare a SQL statement
131
+ */
132
+ prepare(sql) {
133
+ return new MockStatement(this, sql);
134
+ }
135
+
136
+ /**
137
+ * Execute a batch of statements
138
+ */
139
+ async batch(statements) {
140
+ const results = [];
141
+ for (const stmt of statements) {
142
+ results.push(await stmt.run());
143
+ }
144
+ return results;
145
+ }
146
+ }
147
+
148
+ class MockStatement {
149
+ constructor(db, sql) {
150
+ this.db = db;
151
+ this.sql = sql;
152
+ this.bindings = [];
153
+ }
154
+
155
+ bind(...values) {
156
+ this.bindings = values;
157
+ return this;
158
+ }
159
+
160
+ async first() {
161
+ const results = await this.all();
162
+ return results.results?.[0] || null;
163
+ }
164
+
165
+ async all() {
166
+ const { operation, table, data } = this._parseSql();
167
+
168
+ switch (operation) {
169
+ case 'SELECT':
170
+ return { results: this._executeSelect(table) };
171
+ case 'INSERT':
172
+ return { results: this._executeInsert(table, data) };
173
+ case 'UPDATE':
174
+ return { results: this._executeUpdate(table, data) };
175
+ case 'DELETE':
176
+ return { results: this._executeDelete(table) };
177
+ default:
178
+ return { results: [] };
179
+ }
180
+ }
181
+
182
+ async run() {
183
+ return this.all();
184
+ }
185
+
186
+ _parseSql() {
187
+ const sql = this.sql.trim().toUpperCase();
188
+
189
+ if (sql.startsWith('SELECT')) {
190
+ const match = this.sql.match(/FROM\s+(\w+)/i);
191
+ return { operation: 'SELECT', table: match?.[1] };
192
+ }
193
+ if (sql.startsWith('INSERT')) {
194
+ const match = this.sql.match(/INTO\s+(\w+)/i);
195
+ return { operation: 'INSERT', table: match?.[1] };
196
+ }
197
+ if (sql.startsWith('UPDATE')) {
198
+ const match = this.sql.match(/UPDATE\s+(\w+)/i);
199
+ return { operation: 'UPDATE', table: match?.[1] };
200
+ }
201
+ if (sql.startsWith('DELETE')) {
202
+ const match = this.sql.match(/FROM\s+(\w+)/i);
203
+ return { operation: 'DELETE', table: match?.[1] };
204
+ }
205
+
206
+ return { operation: 'UNKNOWN' };
207
+ }
208
+
209
+ _executeSelect(tableName) {
210
+ const table = this.db.tables[tableName] || [];
211
+
212
+ // Handle WHERE clause with bindings
213
+ if (this.sql.includes('WHERE') && this.bindings.length > 0) {
214
+ // Simple WHERE clause parsing
215
+ if (this.sql.includes('template_id = ?')) {
216
+ return table.filter(row => row.template_id === this.bindings[0]);
217
+ }
218
+ if (this.sql.includes('send_id = ?')) {
219
+ return table.filter(row => row.send_id === this.bindings[0]);
220
+ }
221
+ if (this.sql.includes('unsub_token = ?')) {
222
+ return table.filter(row => row.unsub_token === this.bindings[0]);
223
+ }
224
+ if (this.sql.includes('tenant_id = ?')) {
225
+ return table.filter(row => row.tenant_id === this.bindings[0]);
226
+ }
227
+ if (this.sql.includes('setting_key = ?')) {
228
+ return table.filter(row => row.setting_key === this.bindings[0]);
229
+ }
230
+ }
231
+
232
+ return table;
233
+ }
234
+
235
+ _executeInsert(tableName) {
236
+ const table = this.db.tables[tableName];
237
+ if (!table) return [];
238
+
239
+ // Parse column names from SQL
240
+ const colMatch = this.sql.match(/\(([^)]+)\)\s*VALUES/i);
241
+ if (!colMatch) return [];
242
+
243
+ const columns = colMatch[1].split(',').map(c => c.trim());
244
+ const newRow = {};
245
+
246
+ columns.forEach((col, i) => {
247
+ newRow[col] = this.bindings[i];
248
+ });
249
+
250
+ // Handle ON CONFLICT for upserts
251
+ if (this.sql.includes('ON CONFLICT')) {
252
+ const existingIndex = table.findIndex(row => row.template_id === newRow.template_id);
253
+ if (existingIndex >= 0) {
254
+ table[existingIndex] = { ...table[existingIndex], ...newRow };
255
+ return [table[existingIndex]];
256
+ }
257
+ }
258
+
259
+ table.push(newRow);
260
+ return [newRow];
261
+ }
262
+
263
+ _executeUpdate(tableName) {
264
+ const table = this.db.tables[tableName];
265
+ if (!table) return [];
266
+
267
+ // Find the row to update using the last binding (usually the WHERE value)
268
+ const whereValue = this.bindings[this.bindings.length - 1];
269
+
270
+ const updated = [];
271
+ for (const row of table) {
272
+ if (row.template_id === whereValue || row.unsub_token === whereValue || row.setting_key === whereValue) {
273
+ // Apply updates (simplified - just takes values from bindings assuming simple SET col=?)
274
+ // NOTE: This Mock is very simple and assumes bindings order matches SET order
275
+ // For: UPDATE system_settings SET setting_value = ? WHERE setting_key = ?
276
+ // Binding 0 is value, Binding 1 is key
277
+ if (tableName === 'system_settings') {
278
+ row.setting_value = this.bindings[0];
279
+ }
280
+
281
+ row.updated_at = Math.floor(Date.now() / 1000);
282
+ updated.push(row);
283
+ }
284
+ }
285
+
286
+ return updated;
287
+ }
288
+
289
+ _executeDelete(tableName) {
290
+ const table = this.db.tables[tableName];
291
+ if (!table) return [];
292
+
293
+ const deleteValue = this.bindings[0];
294
+ const initialLength = table.length;
295
+
296
+ this.db.tables[tableName] = table.filter(row => row.template_id !== deleteValue);
297
+
298
+ return [{ changes: initialLength - this.db.tables[tableName].length }];
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Create a mock environment object
304
+ */
305
+ export function createMockEnv(options = {}) {
306
+ return {
307
+ DB: new MockD1(options),
308
+ DOMAIN: 'example.com',
309
+ ENVIRONMENT: 'development'
310
+ };
311
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * MockEmailSender - Simulates email sending for testing
3
+ *
4
+ * Instead of actually sending emails, this logs them and stores
5
+ * them in memory so you can view what would have been sent.
6
+ */
7
+
8
+ export class MockEmailSender {
9
+ constructor() {
10
+ this.sentEmails = [];
11
+ this.failNextSend = false;
12
+ }
13
+
14
+ /**
15
+ * Simulate sending an email
16
+ */
17
+ async send({ to, subject, html, text, from }) {
18
+ const email = {
19
+ id: crypto.randomUUID(),
20
+ to,
21
+ from,
22
+ subject,
23
+ html,
24
+ text,
25
+ sentAt: new Date().toISOString(),
26
+ status: this.failNextSend ? 'failed' : 'sent'
27
+ };
28
+
29
+ this.sentEmails.push(email);
30
+
31
+ console.log(`[MockEmailSender] ${email.status.toUpperCase()}: "${subject}" to ${to}`);
32
+
33
+ if (this.failNextSend) {
34
+ this.failNextSend = false;
35
+ return { success: false, error: 'Simulated failure' };
36
+ }
37
+
38
+ return { success: true, messageId: email.id };
39
+ }
40
+
41
+ /**
42
+ * Get all sent emails
43
+ */
44
+ getSentEmails() {
45
+ return this.sentEmails;
46
+ }
47
+
48
+ /**
49
+ * Clear sent emails history
50
+ */
51
+ clear() {
52
+ this.sentEmails = [];
53
+ }
54
+
55
+ /**
56
+ * Make the next send fail (for testing error handling)
57
+ */
58
+ simulateFailure() {
59
+ this.failNextSend = true;
60
+ }
61
+ }
62
+
63
+ // Global instance for the example server
64
+ export const mockEmailSender = new MockEmailSender();
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Mocks index - exports all mock implementations
3
+ */
4
+ export { MockD1, createMockEnv } from './MockD1.js';
5
+ export { MockEmailSender, mockEmailSender } from './MockEmailSender.js';
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@content-growth/content-emailing-examples",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "@content-growth/content-emailing-examples",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "@hono/node-server": "^1.3.0",
12
+ "dotenv": "^16.4.5",
13
+ "hono": "^4.0.0",
14
+ "marked": "^9.1.2",
15
+ "mustache": "^4.2.0"
16
+ }
17
+ },
18
+ "node_modules/@hono/node-server": {
19
+ "version": "1.19.7",
20
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
21
+ "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=18.14.1"
25
+ },
26
+ "peerDependencies": {
27
+ "hono": "^4"
28
+ }
29
+ },
30
+ "node_modules/dotenv": {
31
+ "version": "16.6.1",
32
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
33
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
34
+ "license": "BSD-2-Clause",
35
+ "engines": {
36
+ "node": ">=12"
37
+ },
38
+ "funding": {
39
+ "url": "https://dotenvx.com"
40
+ }
41
+ },
42
+ "node_modules/hono": {
43
+ "version": "4.11.1",
44
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz",
45
+ "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==",
46
+ "license": "MIT",
47
+ "engines": {
48
+ "node": ">=16.9.0"
49
+ }
50
+ },
51
+ "node_modules/marked": {
52
+ "version": "9.1.6",
53
+ "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz",
54
+ "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==",
55
+ "license": "MIT",
56
+ "bin": {
57
+ "marked": "bin/marked.js"
58
+ },
59
+ "engines": {
60
+ "node": ">= 16"
61
+ }
62
+ },
63
+ "node_modules/mustache": {
64
+ "version": "4.2.0",
65
+ "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
66
+ "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
67
+ "license": "MIT",
68
+ "bin": {
69
+ "mustache": "bin/mustache"
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@content-growth/content-emailing-examples",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Example server for testing the email package",
7
+ "scripts": {
8
+ "start": "node server.js",
9
+ "dev": "node --watch server.js"
10
+ },
11
+ "dependencies": {
12
+ "hono": "^4.0.0",
13
+ "@hono/node-server": "^1.3.0",
14
+ "marked": "^9.1.2",
15
+ "mustache": "^4.2.0",
16
+ "dotenv": "^16.4.5"
17
+ }
18
+ }