@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,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);
|