@fufulog/brevomorphic-cms-sdk 1.0.4 โ 1.0.6
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/.kilo/worktrees/chip-sputter/INTEGRATION.md +147 -0
- package/.kilo/worktrees/chip-sputter/LICENSE +192 -0
- package/.kilo/worktrees/chip-sputter/README.md +354 -0
- package/.kilo/worktrees/chip-sputter/env.example +25 -0
- package/.kilo/worktrees/chip-sputter/prd.md +135 -0
- package/.kilo/worktrees/chip-sputter/react/BrevoWysiwyg.css +233 -0
- package/.kilo/worktrees/chip-sputter/react/BrevoWysiwyg.d.ts +16 -0
- package/.kilo/worktrees/chip-sputter/react/BrevoWysiwyg.tsx +387 -0
- package/.kilo/worktrees/chip-sputter/src/brevoClient.ts +183 -0
- package/.kilo/worktrees/chip-sputter/src/controller.ts +186 -0
- package/.kilo/worktrees/chip-sputter/src/index.ts +5 -0
- package/.kilo/worktrees/chip-sputter/src/repository.ts +244 -0
- package/.kilo/worktrees/chip-sputter/src/service.ts +221 -0
- package/.kilo/worktrees/chip-sputter/src/types.ts +77 -0
- package/.kilo/worktrees/chip-sputter/svelte/BrevoWysiwyg.svelte +639 -0
- package/.kilo/worktrees/chip-sputter/svelte/BrevoWysiwyg.svelte.d.ts +10 -0
- package/.kilo/worktrees/chip-sputter/tests/sdk.test.ts +239 -0
- package/.kilo/worktrees/chip-sputter/tsconfig.json +16 -0
- package/dist/controller.d.ts +2 -2
- package/dist/controller.js +17 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/service.js +6 -3
- package/dist/twig.d.ts +10 -0
- package/dist/twig.js +25 -0
- package/package.json +15 -2
- package/react/BrevoWysiwyg.tsx +11 -1
- package/src/controller.ts +20 -3
- package/src/index.ts +1 -0
- package/src/service.ts +7 -3
- package/src/twig.ts +27 -0
- package/svelte/BrevoWysiwyg.svelte +11 -1
- package/tests/sdk.test.ts +51 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { EmailTemplateRepository } from '../src/repository.js';
|
|
2
|
+
import { EmailTemplateService } from '../src/service.js';
|
|
3
|
+
import { EmailTemplateController } from '../src/controller.js';
|
|
4
|
+
import { SQLClient, LocalMapping } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
// Setup basic assertion helper
|
|
7
|
+
function assert(condition: boolean, message: string) {
|
|
8
|
+
if (!condition) {
|
|
9
|
+
throw new Error(`Assertion Failed: ${message}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function runTests() {
|
|
14
|
+
console.log('๐งช Starting Brevo CMS SDK Unit Tests...\n');
|
|
15
|
+
|
|
16
|
+
let testPassedCount = 0;
|
|
17
|
+
let testFailedCount = 0;
|
|
18
|
+
|
|
19
|
+
function runTest(name: string, testFn: () => void | Promise<void>) {
|
|
20
|
+
try {
|
|
21
|
+
const p = testFn();
|
|
22
|
+
if (p instanceof Promise) {
|
|
23
|
+
p.then(() => {
|
|
24
|
+
console.log(`โ
PASSED: ${name}`);
|
|
25
|
+
testPassedCount++;
|
|
26
|
+
}).catch((err) => {
|
|
27
|
+
console.error(`โ FAILED: ${name}`);
|
|
28
|
+
console.error(err);
|
|
29
|
+
testFailedCount++;
|
|
30
|
+
});
|
|
31
|
+
} else {
|
|
32
|
+
console.log(`โ
PASSED: ${name}`);
|
|
33
|
+
testPassedCount++;
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`โ FAILED: ${name}`);
|
|
37
|
+
console.error(err);
|
|
38
|
+
testFailedCount++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 1. REPOSITORY TESTS (SQL PostgreSQL Dialect)
|
|
43
|
+
runTest('Repository - PostgreSQL Queries', async () => {
|
|
44
|
+
const executedQueries: { sql: string; params: any[] }[] = [];
|
|
45
|
+
const mockPgClient: SQLClient = {
|
|
46
|
+
query: async (sql: string, params: any[]) => {
|
|
47
|
+
executedQueries.push({ sql, params });
|
|
48
|
+
// return standard PG rows layout
|
|
49
|
+
return {
|
|
50
|
+
rows: [
|
|
51
|
+
{ id: 1, template_id: 100, event_name: 'test.event', is_active: true, updated_at: new Date() }
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const repo = new EmailTemplateRepository({
|
|
58
|
+
brevoApiKey: 'test-key',
|
|
59
|
+
defaultSender: { email: 'test@example.com' },
|
|
60
|
+
dbType: 'postgres',
|
|
61
|
+
dbClient: mockPgClient
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const mapping = await repo.getMappingByTemplateId(100);
|
|
65
|
+
assert(mapping !== null, 'Should return parsed mapping');
|
|
66
|
+
assert(mapping?.template_id === 100, 'Should match template id');
|
|
67
|
+
assert(executedQueries.length === 1, 'Should execute 1 query');
|
|
68
|
+
assert(executedQueries[0].sql.includes('$1'), 'PostgreSQL should use $1 placeholder');
|
|
69
|
+
|
|
70
|
+
// Test upsert Postgres query
|
|
71
|
+
await repo.upsertMapping(100, 'updated.event', false);
|
|
72
|
+
assert(executedQueries.length === 2, 'Should execute another query');
|
|
73
|
+
assert(executedQueries[1].sql.includes('ON CONFLICT (template_id)'), 'Postgres query should have ON CONFLICT');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 2. REPOSITORY TESTS (SQL MySQL Dialect)
|
|
77
|
+
runTest('Repository - MySQL Queries', async () => {
|
|
78
|
+
const executedQueries: { sql: string; params: any[] }[] = [];
|
|
79
|
+
const mockMySQLClient: SQLClient = {
|
|
80
|
+
query: async (sql: string, params: any[]) => {
|
|
81
|
+
executedQueries.push({ sql, params });
|
|
82
|
+
// return MySQL row arrays
|
|
83
|
+
return [
|
|
84
|
+
[
|
|
85
|
+
{ id: 1, template_id: 200, event_name: 'mysql.event', is_active: true, updated_at: new Date() }
|
|
86
|
+
],
|
|
87
|
+
{} // Fields meta
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const repo = new EmailTemplateRepository({
|
|
93
|
+
brevoApiKey: 'test-key',
|
|
94
|
+
defaultSender: { email: 'test@example.com' },
|
|
95
|
+
dbType: 'mysql',
|
|
96
|
+
dbClient: mockMySQLClient
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const mapping = await repo.getMappingByTemplateId(200);
|
|
100
|
+
assert(mapping !== null, 'Should return MySQL mapped record');
|
|
101
|
+
assert(mapping?.template_id === 200, 'Should match template id');
|
|
102
|
+
assert(executedQueries.length === 1, 'Should execute 1 query');
|
|
103
|
+
assert(executedQueries[0].sql.includes('?'), 'MySQL should use ? placeholders');
|
|
104
|
+
|
|
105
|
+
// Test upsert MySQL query
|
|
106
|
+
await repo.upsertMapping(200, 'updated.event', true);
|
|
107
|
+
assert(executedQueries.length === 2, 'Should execute upsert query');
|
|
108
|
+
assert(executedQueries[1].sql.includes('ON DUPLICATE KEY UPDATE'), 'MySQL should use ON DUPLICATE KEY UPDATE');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 3. REPOSITORY TESTS (Firestore)
|
|
112
|
+
runTest('Repository - Firestore Collections', async () => {
|
|
113
|
+
const firestoreDbState: Record<string, any> = {};
|
|
114
|
+
let whereClauseCalled = false;
|
|
115
|
+
|
|
116
|
+
const mockFirestoreClient = {
|
|
117
|
+
collection: (collectionName: string) => {
|
|
118
|
+
assert(collectionName === 'email_event_templates', 'Should default collection path');
|
|
119
|
+
return {
|
|
120
|
+
doc: (docId: string) => ({
|
|
121
|
+
get: async () => ({
|
|
122
|
+
exists: firestoreDbState[docId] !== undefined,
|
|
123
|
+
data: () => firestoreDbState[docId],
|
|
124
|
+
}),
|
|
125
|
+
set: async (data: any, options: any) => {
|
|
126
|
+
if (options?.merge && firestoreDbState[docId]) {
|
|
127
|
+
firestoreDbState[docId] = { ...firestoreDbState[docId], ...data };
|
|
128
|
+
} else {
|
|
129
|
+
firestoreDbState[docId] = data;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
where: (field: string, op: string, val: any) => {
|
|
134
|
+
whereClauseCalled = true;
|
|
135
|
+
return {
|
|
136
|
+
where: () => ({
|
|
137
|
+
limit: () => ({
|
|
138
|
+
get: async () => ({
|
|
139
|
+
empty: false,
|
|
140
|
+
docs: [
|
|
141
|
+
{
|
|
142
|
+
data: () => ({
|
|
143
|
+
template_id: 300,
|
|
144
|
+
event_name: 'firestore.event',
|
|
145
|
+
is_active: true
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const repo = new EmailTemplateRepository({
|
|
159
|
+
brevoApiKey: 'test-key',
|
|
160
|
+
defaultSender: { email: 'test@example.com' },
|
|
161
|
+
dbType: 'firestore',
|
|
162
|
+
dbClient: mockFirestoreClient
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// JIT Sync Mock
|
|
166
|
+
await repo.insertJITMapping(300);
|
|
167
|
+
const cached = await repo.getMappingByTemplateId(300);
|
|
168
|
+
assert(cached !== null, 'Doc should be created JIT');
|
|
169
|
+
assert(cached?.event_name === '', 'JIT mapping event_name should be empty');
|
|
170
|
+
|
|
171
|
+
// Upsert and get
|
|
172
|
+
await repo.upsertMapping(300, 'firestore.event', true);
|
|
173
|
+
const updated = await repo.getMappingByTemplateId(300);
|
|
174
|
+
assert(updated?.event_name === 'firestore.event', 'Updated event should match');
|
|
175
|
+
assert(updated?.is_active === true, 'Updated active state should match');
|
|
176
|
+
|
|
177
|
+
// Active event query
|
|
178
|
+
const active = await repo.getActiveMappingByEvent('firestore.event');
|
|
179
|
+
assert(active !== null, 'Active event record should match query');
|
|
180
|
+
assert(whereClauseCalled === true, 'Should use firestore where filters');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// 4. SERVICE VALIDATION TESTS
|
|
184
|
+
runTest('Service - Input Validations', async () => {
|
|
185
|
+
const mockPgClient: SQLClient = { query: async () => ({ rows: [] }) };
|
|
186
|
+
const service = new EmailTemplateService({
|
|
187
|
+
brevoApiKey: 'dummy-key',
|
|
188
|
+
defaultSender: { email: 'test@example.com', name: 'Tester' },
|
|
189
|
+
dbType: 'postgres',
|
|
190
|
+
dbClient: mockPgClient
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Test blank name constraint
|
|
194
|
+
try {
|
|
195
|
+
await service.updateTemplate(1, {
|
|
196
|
+
templateName: '',
|
|
197
|
+
subject: 'Sub',
|
|
198
|
+
sender: { email: 'test@example.com' },
|
|
199
|
+
htmlContent: '<html><body>Hello World!</body></html>',
|
|
200
|
+
eventName: 'test',
|
|
201
|
+
isActive: true
|
|
202
|
+
});
|
|
203
|
+
assert(false, 'Should throw error for blank name');
|
|
204
|
+
} catch (e: any) {
|
|
205
|
+
assert(e.message === 'Template name cannot be blank', 'Error message matches blank template validation');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Test minimum HTML content length constraint (<= 10 chars)
|
|
209
|
+
try {
|
|
210
|
+
await service.updateTemplate(1, {
|
|
211
|
+
templateName: 'Valid Name',
|
|
212
|
+
subject: 'Sub',
|
|
213
|
+
sender: { email: 'test@example.com' },
|
|
214
|
+
htmlContent: '<html>',
|
|
215
|
+
eventName: 'test',
|
|
216
|
+
isActive: true
|
|
217
|
+
});
|
|
218
|
+
assert(false, 'Should throw error for short HTML body');
|
|
219
|
+
} catch (e: any) {
|
|
220
|
+
assert(e.message.includes('HTML content must be greater than 10 characters'), 'Error message matches HTML constraint');
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Wait a small duration to let asynchronous assertions print out
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
console.log(`\n๐ Tests complete. Total Passed: ${testPassedCount}, Failed: ${testFailedCount}`);
|
|
227
|
+
if (testFailedCount > 0) {
|
|
228
|
+
process.exit(1);
|
|
229
|
+
} else {
|
|
230
|
+
console.log('๐ All unit tests executed successfully without issues!');
|
|
231
|
+
process.exit(0);
|
|
232
|
+
}
|
|
233
|
+
}, 100);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
runTests().catch((err) => {
|
|
237
|
+
console.error('Fatal crash during test execution:', err);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist", "tests", "svelte", "react"]
|
|
16
|
+
}
|
package/dist/controller.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Request, Response, Router } from 'express';
|
|
1
|
+
import type { Request, Response, Router } from 'express';
|
|
2
2
|
import { EmailTemplateService } from './service.js';
|
|
3
3
|
export declare class EmailTemplateController {
|
|
4
4
|
private service;
|
|
@@ -7,7 +7,7 @@ export declare class EmailTemplateController {
|
|
|
7
7
|
* Generates a fully configured Express Router.
|
|
8
8
|
* Can be directly mounted into any Express app: `app.use('/api/cms', controller.getRouter())`
|
|
9
9
|
*/
|
|
10
|
-
getRouter(): Router;
|
|
10
|
+
getRouter(customRouter?: Router): Router;
|
|
11
11
|
/**
|
|
12
12
|
* GET /templates
|
|
13
13
|
* Lists all templates synchronized from Brevo and mapped locally.
|
package/dist/controller.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
let cachedExpressRouter;
|
|
3
|
+
function getExpressRouter() {
|
|
4
|
+
if (cachedExpressRouter)
|
|
5
|
+
return cachedExpressRouter;
|
|
6
|
+
try {
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const express = require('express');
|
|
9
|
+
cachedExpressRouter = express.Router;
|
|
10
|
+
return cachedExpressRouter;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error("The 'express' package is required to use EmailTemplateController. Please install express in your project: npm install express");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
2
16
|
export class EmailTemplateController {
|
|
3
17
|
service;
|
|
4
18
|
constructor(service) {
|
|
@@ -8,8 +22,8 @@ export class EmailTemplateController {
|
|
|
8
22
|
* Generates a fully configured Express Router.
|
|
9
23
|
* Can be directly mounted into any Express app: `app.use('/api/cms', controller.getRouter())`
|
|
10
24
|
*/
|
|
11
|
-
getRouter() {
|
|
12
|
-
const router =
|
|
25
|
+
getRouter(customRouter) {
|
|
26
|
+
const router = customRouter || getExpressRouter()();
|
|
13
27
|
// Standard express mapping
|
|
14
28
|
router.get('/templates', this.listTemplates.bind(this));
|
|
15
29
|
router.get('/templates/:id', this.getTemplate.bind(this));
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/service.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BrevoClient } from './brevoClient.js';
|
|
2
|
+
import { restoreTwigTags } from './twig.js';
|
|
2
3
|
import { EmailTemplateRepository } from './repository.js';
|
|
3
4
|
export class EmailTemplateService {
|
|
4
5
|
brevoClient;
|
|
@@ -108,6 +109,8 @@ export class EmailTemplateService {
|
|
|
108
109
|
if (!input.htmlContent || input.htmlContent.length <= 10) {
|
|
109
110
|
throw new Error('HTML content must be greater than 10 characters');
|
|
110
111
|
}
|
|
112
|
+
// Editors that read innerHTML send Twig operators as entities (>), which Brevo rejects
|
|
113
|
+
const htmlContent = restoreTwigTags(input.htmlContent);
|
|
111
114
|
const sender = {
|
|
112
115
|
email: input.sender?.email || this.defaultSender.email,
|
|
113
116
|
name: input.sender?.name || this.defaultSender.name || '',
|
|
@@ -117,7 +120,7 @@ export class EmailTemplateService {
|
|
|
117
120
|
templateName: input.templateName,
|
|
118
121
|
subject: input.subject,
|
|
119
122
|
sender: sender,
|
|
120
|
-
htmlContent
|
|
123
|
+
htmlContent,
|
|
121
124
|
isActive: input.isActive,
|
|
122
125
|
});
|
|
123
126
|
// 2. Update local mapping DB
|
|
@@ -129,7 +132,7 @@ export class EmailTemplateService {
|
|
|
129
132
|
eventName: input.eventName || '',
|
|
130
133
|
isActive: input.isActive,
|
|
131
134
|
sender: sender,
|
|
132
|
-
htmlContent
|
|
135
|
+
htmlContent,
|
|
133
136
|
};
|
|
134
137
|
}
|
|
135
138
|
/**
|
|
@@ -143,7 +146,7 @@ export class EmailTemplateService {
|
|
|
143
146
|
templateName: bt.name,
|
|
144
147
|
subject: bt.subject,
|
|
145
148
|
sender: bt.sender || this.defaultSender,
|
|
146
|
-
htmlContent: bt.htmlContent || '',
|
|
149
|
+
htmlContent: restoreTwigTags(bt.htmlContent || ''),
|
|
147
150
|
isActive: isActive,
|
|
148
151
|
});
|
|
149
152
|
// 3. Update local DB mapping status
|
package/dist/twig.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo HTML entity encoding that breaks Brevo's Twig parser.
|
|
3
|
+
*
|
|
4
|
+
* Reading a contentEditable element's innerHTML escapes text, so a condition like
|
|
5
|
+
* `{% if params.count > 1 %}` comes back as `{% if params.count > 1 %}` and Brevo
|
|
6
|
+
* rejects it as a malformed if-condition. Brackets are restored everywhere; operators,
|
|
7
|
+
* quotes and ampersands only inside `{% ... %}` and `{{ ... }}` tags, so the rest of
|
|
8
|
+
* the HTML keeps its escaping.
|
|
9
|
+
*/
|
|
10
|
+
export declare function restoreTwigTags(html: string): string;
|
package/dist/twig.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo HTML entity encoding that breaks Brevo's Twig parser.
|
|
3
|
+
*
|
|
4
|
+
* Reading a contentEditable element's innerHTML escapes text, so a condition like
|
|
5
|
+
* `{% if params.count > 1 %}` comes back as `{% if params.count > 1 %}` and Brevo
|
|
6
|
+
* rejects it as a malformed if-condition. Brackets are restored everywhere; operators,
|
|
7
|
+
* quotes and ampersands only inside `{% ... %}` and `{{ ... }}` tags, so the rest of
|
|
8
|
+
* the HTML keeps its escaping.
|
|
9
|
+
*/
|
|
10
|
+
export function restoreTwigTags(html) {
|
|
11
|
+
return html
|
|
12
|
+
.replace(/{/g, '{')
|
|
13
|
+
.replace(/}/g, '}')
|
|
14
|
+
.replace(/{/g, '{')
|
|
15
|
+
.replace(/}/g, '}')
|
|
16
|
+
.replace(/%/g, '%')
|
|
17
|
+
.replace(/%/g, '%')
|
|
18
|
+
.replace(/\{%[\s\S]*?%\}|\{\{[\s\S]*?\}\}/g, (tag) => tag
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/g, "'")
|
|
23
|
+
.replace(/ /g, ' ')
|
|
24
|
+
.replace(/&/g, '&'));
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fufulog/brevomorphic-cms-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SDK library to bootstrap Brevo email templates and manage local mappings in Postgres, MySQL, or Firestore",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
13
|
},
|
|
14
|
+
"./express": {
|
|
15
|
+
"types": "./dist/controller.d.ts",
|
|
16
|
+
"import": "./dist/controller.js",
|
|
17
|
+
"default": "./dist/controller.js"
|
|
18
|
+
},
|
|
14
19
|
"./svelte/BrevoWysiwyg.svelte": {
|
|
15
20
|
"types": "./svelte/BrevoWysiwyg.svelte.d.ts",
|
|
16
21
|
"import": "./svelte/BrevoWysiwyg.svelte",
|
|
@@ -57,5 +62,13 @@
|
|
|
57
62
|
},
|
|
58
63
|
"publishConfig": {
|
|
59
64
|
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"express": "^4.19.2 || ^5.0.0"
|
|
68
|
+
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"express": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
60
73
|
}
|
|
61
|
-
}
|
|
74
|
+
}
|
package/react/BrevoWysiwyg.tsx
CHANGED
|
@@ -85,7 +85,17 @@ export default function BrevoWysiwyg({
|
|
|
85
85
|
.replace(/{/g, '{')
|
|
86
86
|
.replace(/}/g, '}')
|
|
87
87
|
.replace(/%/g, '%')
|
|
88
|
-
.replace(/%/g, '%')
|
|
88
|
+
.replace(/%/g, '%')
|
|
89
|
+
// innerHTML escapes > < & and quotes in text, which breaks Twig conditions like {% if x > 1 %}
|
|
90
|
+
.replace(/\{%[\s\S]*?%\}|\{\{[\s\S]*?\}\}/g, (tag) =>
|
|
91
|
+
tag
|
|
92
|
+
.replace(/>/g, '>')
|
|
93
|
+
.replace(/</g, '<')
|
|
94
|
+
.replace(/"/g, '"')
|
|
95
|
+
.replace(/'/g, "'")
|
|
96
|
+
.replace(/ /g, ' ')
|
|
97
|
+
.replace(/&/g, '&')
|
|
98
|
+
);
|
|
89
99
|
}, []);
|
|
90
100
|
|
|
91
101
|
const syncFromEditor = useCallback(() => {
|
package/src/controller.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
|
-
import { Request, Response, Router } from 'express';
|
|
1
|
+
import type { Request, Response, Router } from 'express';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { EmailTemplateService } from './service.js';
|
|
3
4
|
|
|
5
|
+
let cachedExpressRouter: any;
|
|
6
|
+
|
|
7
|
+
function getExpressRouter(): any {
|
|
8
|
+
if (cachedExpressRouter) return cachedExpressRouter;
|
|
9
|
+
try {
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const express = require('express');
|
|
12
|
+
cachedExpressRouter = express.Router;
|
|
13
|
+
return cachedExpressRouter;
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"The 'express' package is required to use EmailTemplateController. Please install express in your project: npm install express"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
export class EmailTemplateController {
|
|
5
22
|
private service: EmailTemplateService;
|
|
6
23
|
|
|
@@ -12,8 +29,8 @@ export class EmailTemplateController {
|
|
|
12
29
|
* Generates a fully configured Express Router.
|
|
13
30
|
* Can be directly mounted into any Express app: `app.use('/api/cms', controller.getRouter())`
|
|
14
31
|
*/
|
|
15
|
-
getRouter(): Router {
|
|
16
|
-
const router =
|
|
32
|
+
getRouter(customRouter?: Router): Router {
|
|
33
|
+
const router = customRouter || getExpressRouter()();
|
|
17
34
|
|
|
18
35
|
// Standard express mapping
|
|
19
36
|
router.get('/templates', this.listTemplates.bind(this));
|
package/src/index.ts
CHANGED
package/src/service.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BrevoClient } from './brevoClient.js';
|
|
2
|
+
import { restoreTwigTags } from './twig.js';
|
|
2
3
|
import { EmailTemplateRepository } from './repository.js';
|
|
3
4
|
import { SDKConfig, CombinedTemplate, CreateTemplateInput, UpdateTemplateInput, BrevoSender } from './types.js';
|
|
4
5
|
|
|
@@ -129,6 +130,9 @@ export class EmailTemplateService {
|
|
|
129
130
|
throw new Error('HTML content must be greater than 10 characters');
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
// Editors that read innerHTML send Twig operators as entities (>), which Brevo rejects
|
|
134
|
+
const htmlContent = restoreTwigTags(input.htmlContent);
|
|
135
|
+
|
|
132
136
|
const sender: BrevoSender = {
|
|
133
137
|
email: input.sender?.email || this.defaultSender.email,
|
|
134
138
|
name: input.sender?.name || this.defaultSender.name || '',
|
|
@@ -139,7 +143,7 @@ export class EmailTemplateService {
|
|
|
139
143
|
templateName: input.templateName,
|
|
140
144
|
subject: input.subject,
|
|
141
145
|
sender: sender,
|
|
142
|
-
htmlContent
|
|
146
|
+
htmlContent,
|
|
143
147
|
isActive: input.isActive,
|
|
144
148
|
});
|
|
145
149
|
|
|
@@ -153,7 +157,7 @@ export class EmailTemplateService {
|
|
|
153
157
|
eventName: input.eventName || '',
|
|
154
158
|
isActive: input.isActive,
|
|
155
159
|
sender: sender,
|
|
156
|
-
htmlContent
|
|
160
|
+
htmlContent,
|
|
157
161
|
};
|
|
158
162
|
}
|
|
159
163
|
|
|
@@ -169,7 +173,7 @@ export class EmailTemplateService {
|
|
|
169
173
|
templateName: bt.name,
|
|
170
174
|
subject: bt.subject,
|
|
171
175
|
sender: bt.sender || this.defaultSender,
|
|
172
|
-
htmlContent: bt.htmlContent || '',
|
|
176
|
+
htmlContent: restoreTwigTags(bt.htmlContent || ''),
|
|
173
177
|
isActive: isActive,
|
|
174
178
|
});
|
|
175
179
|
|
package/src/twig.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo HTML entity encoding that breaks Brevo's Twig parser.
|
|
3
|
+
*
|
|
4
|
+
* Reading a contentEditable element's innerHTML escapes text, so a condition like
|
|
5
|
+
* `{% if params.count > 1 %}` comes back as `{% if params.count > 1 %}` and Brevo
|
|
6
|
+
* rejects it as a malformed if-condition. Brackets are restored everywhere; operators,
|
|
7
|
+
* quotes and ampersands only inside `{% ... %}` and `{{ ... }}` tags, so the rest of
|
|
8
|
+
* the HTML keeps its escaping.
|
|
9
|
+
*/
|
|
10
|
+
export function restoreTwigTags(html: string): string {
|
|
11
|
+
return html
|
|
12
|
+
.replace(/{/g, '{')
|
|
13
|
+
.replace(/}/g, '}')
|
|
14
|
+
.replace(/{/g, '{')
|
|
15
|
+
.replace(/}/g, '}')
|
|
16
|
+
.replace(/%/g, '%')
|
|
17
|
+
.replace(/%/g, '%')
|
|
18
|
+
.replace(/\{%[\s\S]*?%\}|\{\{[\s\S]*?\}\}/g, (tag) =>
|
|
19
|
+
tag
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, "'")
|
|
24
|
+
.replace(/ /g, ' ')
|
|
25
|
+
.replace(/&/g, '&')
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -70,7 +70,17 @@
|
|
|
70
70
|
.replace(/{/g, "{")
|
|
71
71
|
.replace(/}/g, "}")
|
|
72
72
|
.replace(/%/g, "%")
|
|
73
|
-
.replace(/%/g, "%")
|
|
73
|
+
.replace(/%/g, "%")
|
|
74
|
+
// innerHTML escapes > < & and quotes in text, which breaks Twig conditions like {% if x > 1 %}
|
|
75
|
+
.replace(/\{%[\s\S]*?%\}|\{\{[\s\S]*?\}\}/g, (tag) =>
|
|
76
|
+
tag
|
|
77
|
+
.replace(/>/g, ">")
|
|
78
|
+
.replace(/</g, "<")
|
|
79
|
+
.replace(/"/g, '"')
|
|
80
|
+
.replace(/'/g, "'")
|
|
81
|
+
.replace(/ /g, " ")
|
|
82
|
+
.replace(/&/g, "&")
|
|
83
|
+
);
|
|
74
84
|
value = cleaned;
|
|
75
85
|
dispatch("change", cleaned);
|
|
76
86
|
}
|
package/tests/sdk.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { EmailTemplateRepository } from '../src/repository.js';
|
|
|
2
2
|
import { EmailTemplateService } from '../src/service.js';
|
|
3
3
|
import { EmailTemplateController } from '../src/controller.js';
|
|
4
4
|
import { SQLClient, LocalMapping } from '../src/types.js';
|
|
5
|
+
import { restoreTwigTags } from '../src/twig.js';
|
|
5
6
|
|
|
6
7
|
// Setup basic assertion helper
|
|
7
8
|
function assert(condition: boolean, message: string) {
|
|
@@ -221,6 +222,56 @@ async function runTests() {
|
|
|
221
222
|
}
|
|
222
223
|
});
|
|
223
224
|
|
|
225
|
+
// 5. TWIG ENTITY RESTORE TESTS
|
|
226
|
+
runTest('restoreTwigTags - Decodes operators inside Twig tags only', () => {
|
|
227
|
+
const input = '<p>A > B & C</p>{% if params.count > 1 %}x{% endif %}{{ params.a && params.b }}{% if params.name == "Jo" %}y{% endif %}';
|
|
228
|
+
const output = restoreTwigTags(input);
|
|
229
|
+
assert(output.includes('<p>A > B & C</p>'), 'HTML text outside tags keeps its escaping');
|
|
230
|
+
assert(output.includes('{% if params.count > 1 %}'), 'Comparison operator is restored');
|
|
231
|
+
assert(output.includes('{{ params.a && params.b }}'), 'Ampersands are restored inside output tags');
|
|
232
|
+
assert(output.includes('{% if params.name == "Jo" %}'), 'Quotes are restored');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
runTest('restoreTwigTags - Restores encoded brackets', () => {
|
|
236
|
+
assert(restoreTwigTags('{{ params.x }}') === '{{ params.x }}', 'Numeric brace entities are restored');
|
|
237
|
+
assert(restoreTwigTags('{% if a > 1 %}') === '{% if a > 1 %}', 'Encoded tag delimiters and operators are restored');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
runTest('Service - updateTemplate sends restored Twig to Brevo', async () => {
|
|
241
|
+
const mockPgClient: SQLClient = { query: async () => ({ rows: [] }) };
|
|
242
|
+
const service = new EmailTemplateService({
|
|
243
|
+
brevoApiKey: 'dummy-key',
|
|
244
|
+
defaultSender: { email: 'test@example.com' },
|
|
245
|
+
dbType: 'postgres',
|
|
246
|
+
dbClient: mockPgClient
|
|
247
|
+
});
|
|
248
|
+
let sentHtml = '';
|
|
249
|
+
(service as any).brevoClient = { updateTemplate: async (_id: number, params: { htmlContent: string }) => { sentHtml = params.htmlContent; } };
|
|
250
|
+
const result = await service.updateTemplate(7, {
|
|
251
|
+
templateName: 'Invite',
|
|
252
|
+
subject: 'You have a ticket',
|
|
253
|
+
htmlContent: '<h1>{% if params.ticketCount > 1 %}Tickets{% else %}Ticket{% endif %}</h1>',
|
|
254
|
+
eventName: 'ticket.invite',
|
|
255
|
+
isActive: true
|
|
256
|
+
});
|
|
257
|
+
assert(sentHtml === '<h1>{% if params.ticketCount > 1 %}Tickets{% else %}Ticket{% endif %}</h1>', 'Brevo receives the decoded condition');
|
|
258
|
+
assert(result.htmlContent === sentHtml, 'Returned template matches what was sent');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// 6. CONTROLLER TESTS
|
|
262
|
+
runTest('Controller - getRouter with Express', async () => {
|
|
263
|
+
const mockPgClient: SQLClient = { query: async () => ({ rows: [] }) };
|
|
264
|
+
const service = new EmailTemplateService({
|
|
265
|
+
brevoApiKey: 'dummy-key',
|
|
266
|
+
defaultSender: { email: 'test@example.com' },
|
|
267
|
+
dbType: 'postgres',
|
|
268
|
+
dbClient: mockPgClient
|
|
269
|
+
});
|
|
270
|
+
const controller = new EmailTemplateController(service);
|
|
271
|
+
const router = controller.getRouter();
|
|
272
|
+
assert(typeof router === 'function' || typeof router === 'object', 'Router should be created');
|
|
273
|
+
});
|
|
274
|
+
|
|
224
275
|
// Wait a small duration to let asynchronous assertions print out
|
|
225
276
|
setTimeout(() => {
|
|
226
277
|
console.log(`\n๐ Tests complete. Total Passed: ${testPassedCount}, Failed: ${testFailedCount}`);
|