kempo 4.7.0 → 4.9.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.
Files changed (132) hide show
  1. package/README.md +10 -4
  2. package/bin/cli.js +38 -34
  3. package/dist/admin/components/EmailTemplates.js +25 -0
  4. package/dist/admin/components/EmailTemplates.js.map +1 -0
  5. package/dist/admin/components/Emails.js +49 -0
  6. package/dist/admin/components/Emails.js.map +1 -0
  7. package/dist/admin/components/Updates.js +117 -0
  8. package/dist/admin/components/Updates.js.map +1 -0
  9. package/dist/admin/components/settings/AdminSettings.js +7 -0
  10. package/dist/admin/components/settings/AdminSettings.js.map +1 -0
  11. package/dist/admin/components/settings/BooleanSetting.js +2 -0
  12. package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
  13. package/dist/admin/components/settings/JsonSetting.js +2 -0
  14. package/dist/admin/components/settings/JsonSetting.js.map +1 -0
  15. package/dist/admin/components/settings/NumberSetting.js +2 -0
  16. package/dist/admin/components/settings/NumberSetting.js.map +1 -0
  17. package/dist/admin/components/settings/SecretSetting.js +2 -0
  18. package/dist/admin/components/settings/SecretSetting.js.map +1 -0
  19. package/dist/admin/components/settings/Setting.js +2 -0
  20. package/dist/admin/components/settings/Setting.js.map +1 -0
  21. package/dist/admin/components/settings/SettingEditor.js +2 -0
  22. package/dist/admin/components/settings/SettingEditor.js.map +1 -0
  23. package/dist/admin/components/settings/SettingsPanel.js +24 -0
  24. package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
  25. package/dist/admin/components/settings/StringSetting.js +2 -0
  26. package/dist/admin/components/settings/StringSetting.js.map +1 -0
  27. package/dist/admin/components/settings/humanize.js +2 -0
  28. package/dist/admin/components/settings/humanize.js.map +1 -0
  29. package/dist/admin/emails/index.page.html +14 -0
  30. package/dist/admin/icons/mail.svg +1 -0
  31. package/dist/admin/nav.fragment.html +12 -0
  32. package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
  33. package/dist/admin/settings/index.page.html +6 -289
  34. package/dist/admin/updates/index.page.html +14 -0
  35. package/dist/kempo/api/email-templates/GET.js +2 -0
  36. package/dist/kempo/api/email-templates/GET.js.map +1 -0
  37. package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
  38. package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
  39. package/dist/kempo/api/email-templates/file/GET.js +2 -0
  40. package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
  41. package/dist/kempo/api/email-templates/file/PUT.js +2 -0
  42. package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
  43. package/dist/kempo/api/email-templates/preview/GET.js +2 -0
  44. package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
  45. package/dist/kempo/api/emails/GET.js +2 -0
  46. package/dist/kempo/api/emails/GET.js.map +1 -0
  47. package/dist/kempo/api/emails/[id]/GET.js +2 -0
  48. package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
  49. package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
  50. package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
  51. package/dist/kempo/api/emails/test/POST.js +2 -0
  52. package/dist/kempo/api/emails/test/POST.js.map +1 -0
  53. package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
  54. package/dist/kempo/api/settings/[owner]/[name]/POST.js.map +1 -1
  55. package/dist/kempo/api/updates/GET.js +2 -0
  56. package/dist/kempo/api/updates/GET.js.map +1 -0
  57. package/dist/kempo/api/updates/apply/POST.js +2 -0
  58. package/dist/kempo/api/updates/apply/POST.js.map +1 -0
  59. package/dist/kempo/api/updates/check/POST.js +2 -0
  60. package/dist/kempo/api/updates/check/POST.js.map +1 -0
  61. package/dist/kempo/api/updates/discard/POST.js +2 -0
  62. package/dist/kempo/api/updates/discard/POST.js.map +1 -0
  63. package/dist/kempo/api/updates/prepare/POST.js +2 -0
  64. package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
  65. package/dist/kempo/api/updates/rollback/POST.js +2 -0
  66. package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
  67. package/dist/kempo/fetch.js +1 -1
  68. package/dist/kempo/fetch.js.map +1 -1
  69. package/dist/kempo/sdk.js +1 -1
  70. package/dist/kempo/sdk.js.map +1 -1
  71. package/docker/.dockerignore +9 -0
  72. package/docker/Dockerfile +25 -0
  73. package/docker/docker-compose.prod.yml +39 -0
  74. package/docker/entrypoint.mjs +96 -0
  75. package/middleware/kempo.js +71 -0
  76. package/package.json +4 -3
  77. package/server/db/schema.js +44 -0
  78. package/server/db/upgrades/README.md +20 -0
  79. package/server/sdk.js +8 -0
  80. package/server/utils/email/defaultSender.js +2 -0
  81. package/server/utils/email/dispatchEmail.js +56 -0
  82. package/server/utils/email/emailSettings.js +21 -0
  83. package/server/utils/email/emailTemplatePaths.js +36 -0
  84. package/server/utils/email/getEmail.js +14 -0
  85. package/server/utils/email/getSenderEmail.js +11 -0
  86. package/server/utils/email/getSenderFrom.js +18 -0
  87. package/server/utils/email/isValidSender.js +4 -0
  88. package/server/utils/email/listEmailTemplates.js +23 -0
  89. package/server/utils/email/listEmails.js +35 -0
  90. package/server/utils/email/maintainEmails.js +46 -0
  91. package/server/utils/email/pruneEmails.js +29 -0
  92. package/server/utils/email/readEmailTemplate.js +28 -0
  93. package/server/utils/email/resetEmailTemplate.js +15 -0
  94. package/server/utils/email/retryEmail.js +21 -0
  95. package/server/utils/email/saveEmailTemplate.js +38 -0
  96. package/server/utils/email/sendEmail.js +22 -32
  97. package/server/utils/email/sendEmailFromTemplate.js +2 -2
  98. package/server/utils/extensions/checkExtensionUpdate.js +9 -36
  99. package/server/utils/extensions/reconcileExtensions.js +90 -0
  100. package/server/utils/extensions/updateExtension.js +14 -153
  101. package/server/utils/extensions/upgradeExtension.js +117 -0
  102. package/server/utils/extensions/writeSiteFile.js +66 -0
  103. package/server/utils/hooks/triggerHook.js +17 -4
  104. package/server/utils/settings/listSettings.js +10 -5
  105. package/server/utils/settings/setSetting.js +4 -0
  106. package/server/utils/settings/settingRules.js +21 -0
  107. package/server/utils/settings/systemSettings.js +41 -0
  108. package/server/utils/setup/runUpgrades.js +70 -0
  109. package/server/utils/setup/seedDatabase.js +5 -60
  110. package/server/utils/setup/seedIfNew.js +21 -0
  111. package/server/utils/setup/syncCoreSchema.js +130 -0
  112. package/server/utils/setup/systemPermissions.js +77 -0
  113. package/server/utils/updates/applyStagedUpdate.js +95 -0
  114. package/server/utils/updates/applyUpdate.js +76 -0
  115. package/server/utils/updates/backupDatabase.js +35 -0
  116. package/server/utils/updates/discardUpdate.js +11 -0
  117. package/server/utils/updates/extensionTargets.js +101 -0
  118. package/server/utils/updates/fetchManifest.js +26 -0
  119. package/server/utils/updates/getExtensionUpdates.js +70 -0
  120. package/server/utils/updates/getInstallInfo.js +25 -0
  121. package/server/utils/updates/getUpdateStatus.js +57 -0
  122. package/server/utils/updates/helpers.js +30 -0
  123. package/server/utils/updates/notifySchemaAttention.js +35 -0
  124. package/server/utils/updates/npm.js +41 -0
  125. package/server/utils/updates/prepareUpdate.js +109 -0
  126. package/server/utils/updates/releaseStatus.js +23 -0
  127. package/server/utils/updates/restart.js +64 -0
  128. package/server/utils/updates/rollbackUpdate.js +56 -0
  129. package/server/utils/updates/runUpdateCheck.js +52 -0
  130. package/server/utils/updates/runtime.js +15 -0
  131. package/server/utils/updates/siteLayout.js +200 -0
  132. package/server/utils/updates/starterFiles.js +118 -0
@@ -0,0 +1,96 @@
1
+ import { spawn, spawnSync } from 'child_process';
2
+ import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { createGunzip } from 'zlib';
5
+ import { pipeline } from 'stream/promises';
6
+ import { countAttempt, paths, performSwitch, restoreFiles, rollBack, seedSite, shouldRollBack } from './siteLayout.js';
7
+
8
+ /*
9
+ The image's startup script. It runs before the site, as the container's main process, and does what must not be left to
10
+ the site itself:
11
+
12
+ 1. First start: fills the volume from the image (/seed) with the site as it was built.
13
+ 2. A new version waiting (state/switch.json asked for it): moves its code into place, while nothing is running.
14
+ 3. A new version that has not proved itself and keeps stopping, or says it failed: puts the previous code back,
15
+ the files an update changed, and the database too when the update changed data (from the backup it took first).
16
+ 4. After a change of Node version: rebuilds the compiled modules (bcrypt) the volume was built with.
17
+ 5. Starts the site, passes signals to it and ends with its exit code, so the container's restart policy sees what
18
+ the site did (the site ends with 75 to ask for a restart after an update).
19
+
20
+ Environment: KEMPO_SITE_ROOT (the volume, /site), KEMPO_SEED (the site as built, /seed), KEMPO_START_COMMAND (default
21
+ `npm start`, or a JSON array when a path has spaces), and DATABASE_URL, which a database restore needs.
22
+ */
23
+ const siteRoot = process.env.KEMPO_SITE_ROOT || '/site';
24
+ const seed = process.env.KEMPO_SEED || '/seed';
25
+ const { app, state } = paths(siteRoot);
26
+
27
+ const say = message => console.log(`[kempo] ${message}`);
28
+
29
+ /* Puts a backup taken before a data-changing update back: a plain-SQL dump, gzipped, with its own DROP statements. */
30
+ const restoreDatabase = async file => {
31
+ if(!file || !existsSync(file)) return false;
32
+ const psql = spawn('psql', ['-v', 'ON_ERROR_STOP=1', '-q', process.env.DATABASE_URL], { stdio: ['pipe', 'inherit', 'inherit'] });
33
+ const finished = new Promise((resolve, reject) => {
34
+ psql.on('error', reject);
35
+ psql.on('exit', code => (code === 0 ? resolve() : reject(new Error(`psql ended with ${code}`))));
36
+ });
37
+ await Promise.all([pipeline(createReadStream(file), createGunzip(), psql.stdin), finished]);
38
+ return true;
39
+ };
40
+
41
+ const goBack = async reason => {
42
+ say(`The new version of Kempo is not working (${reason}). Going back to the one before it.`);
43
+ const result = rollBack({ siteRoot, reason });
44
+ if(!result.ok){
45
+ say(result.reason);
46
+ return;
47
+ }
48
+ say(`Back on Kempo ${result.to}.`);
49
+ if(result.dbBackup){
50
+ try {
51
+ say('Restoring the database from the backup taken before the update.');
52
+ await restoreDatabase(result.dbBackup);
53
+ } catch(error) {
54
+ say(`The database could not be restored (${error.message}). The backup is at ${result.dbBackup}.`);
55
+ }
56
+ }
57
+ };
58
+
59
+ if(seedSite({ siteRoot, seed })) say('First start: the site was copied onto the volume.');
60
+
61
+ const switched = performSwitch(siteRoot);
62
+ if(switched){
63
+ say(`Kempo ${switched.from.version} -> ${switched.version}.`);
64
+ if(switched.kind === 'rollback'){
65
+ restoreFiles({ siteRoot, backup: switched.filesBackup });
66
+ if(switched.dbBackup){
67
+ try {
68
+ say('Restoring the database from the backup taken before the update.');
69
+ await restoreDatabase(switched.dbBackup);
70
+ } catch(error) {
71
+ say(`The database could not be restored (${error.message}). The backup is at ${switched.dbBackup}.`);
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ const counted = countAttempt(siteRoot);
78
+ if(shouldRollBack(counted)) await goBack(counted.failed ? counted.failure || 'a data migration failed' : 'it stopped too many times');
79
+
80
+ const nodeFile = join(state, 'node-version');
81
+ const builtWith = existsSync(nodeFile) ? readFileSync(nodeFile, 'utf8').trim() : null;
82
+ if(builtWith && builtWith !== process.version){
83
+ say(`Node changed from ${builtWith} to ${process.version}: rebuilding compiled modules.`);
84
+ spawnSync('npm', ['rebuild'], { cwd: app, stdio: 'inherit' });
85
+ }
86
+ mkdirSync(state, { recursive: true });
87
+ writeFileSync(nodeFile, process.version);
88
+
89
+ const startCommand = process.env.KEMPO_START_COMMAND || 'npm start';
90
+ const [command, ...args] = startCommand.startsWith('[') ? JSON.parse(startCommand) : startCommand.split(' ');
91
+ const site = spawn(command, args, { cwd: app, stdio: 'inherit', env: { ...process.env, KEMPO_SITE_ROOT: siteRoot } });
92
+
93
+ for(const signal of ['SIGTERM', 'SIGINT']){
94
+ process.on(signal, () => site.kill(signal));
95
+ }
96
+ site.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
@@ -13,6 +13,16 @@ import triggerHook from '../server/utils/hooks/triggerHook.js';
13
13
  import { ADMIN_GLOBALS_DIR } from '../server/utils/admin-global-content/helpers.js';
14
14
  import { startScheduler } from '../server/utils/scheduler/scheduler.js';
15
15
  import { ensureEncryptionKey } from '../server/utils/settings/ensureEncryptionKey.js';
16
+ import syncCoreSchema from '../server/utils/setup/syncCoreSchema.js';
17
+ import runUpgrades from '../server/utils/setup/runUpgrades.js';
18
+ import seedIfNew from '../server/utils/setup/seedIfNew.js';
19
+ import reconcileExtensions from '../server/utils/extensions/reconcileExtensions.js';
20
+ import { flagFailed, markHealthy } from '../server/utils/updates/siteLayout.js';
21
+ import notifySchemaAttention from '../server/utils/updates/notifySchemaAttention.js';
22
+ import { ensureSystemSettings } from '../server/utils/settings/systemSettings.js';
23
+ import { ensureSystemPermissions } from '../server/utils/setup/systemPermissions.js';
24
+ import registerJobs from '../server/utils/scheduler/registerJobs.js';
25
+ import '../server/utils/updates/runtime.js';
16
26
  import { resolveAlias } from '../server/utils/extensions/aliases.js';
17
27
  import { normalizeScopes } from '../server/utils/extensions/scopes.js';
18
28
 
@@ -184,6 +194,11 @@ const serveDir = async (dirPath, method, request, response, resolveDir, rootDir,
184
194
  export default config => {
185
195
  startScheduler();
186
196
 
197
+ /* A site that runs from a Docker volume has just switched to new code: once it has stayed up a minute the switch is final. */
198
+ if(process.env.KEMPO_SITE_ROOT && process.env.KEMPO_DB !== 'pglite'){
199
+ setTimeout(() => markHealthy(process.env.KEMPO_SITE_ROOT), 60000).unref();
200
+ }
201
+
187
202
  /*
188
203
  The key that encrypts stored credentials exists before any request needs it, generated into .env the
189
204
  first time when the site has none. Opt out with `autoEncryptionKey: false` in the server config or
@@ -194,6 +209,61 @@ export default config => {
194
209
  : ensureEncryptionKey().then(([error]) => {
195
210
  if(error) console.error(`[kempo] ${error.msg}`);
196
211
  });
212
+ /*
213
+ What kempo added in a later version appears on an older site at start: its tables and columns (additive only, see syncCoreSchema), its settings (so they show on the settings screen and can be
214
+ edited), its permissions, and its own scheduled jobs (the email retries and cleanup, and the check for a newer kempo). Only what is missing is added, and never in
215
+ the in-memory test database (nothing is set up there yet).
216
+ */
217
+ const systemSettingsReady = process.env.KEMPO_DB === 'pglite'
218
+ ? Promise.resolve()
219
+ : (async () => {
220
+ for(const [step, run] of [
221
+ ['schema', async () => {
222
+ const [error, result] = await syncCoreSchema();
223
+ if(result?.applied.length) console.log(`[kempo] Database brought up to date: ${result.applied.join('; ')}`);
224
+ for(const { change, reason } of result?.failed ?? []) console.error(`[kempo] Database change failed: ${change}: ${reason}`);
225
+ return [error];
226
+ }],
227
+ ['seed', async () => {
228
+ const [error, result] = await seedIfNew();
229
+ if(result?.seeded) console.log('[kempo] New database: its groups, permissions and settings were set up. Open /setup to create the first administrator.');
230
+ return [error];
231
+ }],
232
+ ['upgrades', async () => {
233
+ const [error, result] = await runUpgrades();
234
+ if(result?.ran.length) console.log(`[kempo] Data migrations run: ${result.ran.join(', ')}`);
235
+ if(error){
236
+ console.error(`[kempo] ${error.msg}`);
237
+ if(error.breaking && process.env.KEMPO_SITE_ROOT){
238
+ flagFailed({ siteRoot: process.env.KEMPO_SITE_ROOT, reason: error.msg });
239
+ process.exit(1);
240
+ }
241
+ }
242
+ return [null];
243
+ }],
244
+ ['settings', ensureSystemSettings],
245
+ ['permissions', ensureSystemPermissions],
246
+ ['extensions', async () => {
247
+ const [error, result] = await reconcileExtensions();
248
+ for(const { name, from, to } of result?.upgraded ?? []) console.log(`[kempo] Extension ${name} brought up to date: ${from} -> ${to}`);
249
+ for(const { name, from, to } of result?.reverted ?? []) console.log(`[kempo] Extension ${name} is back on ${to} (was ${from})`);
250
+ for(const { name, reason } of result?.failed ?? []) console.error(`[kempo] Extension ${name} could not be brought up to date: ${reason}`);
251
+ if(result?.failed.some(item => item.major) && process.env.KEMPO_SITE_ROOT){
252
+ flagFailed({ siteRoot: process.env.KEMPO_SITE_ROOT, reason: result.failed.filter(item => item.major).map(item => `${item.name}: ${item.reason}`).join('; ') });
253
+ process.exit(1);
254
+ }
255
+ return [error];
256
+ }],
257
+ ['schema notice', notifySchemaAttention],
258
+ ['jobs', () => registerJobs({ owner: 'kempo', jobs: [
259
+ { name: 'email-maintenance', every: '1m', callback: './server/utils/email/maintainEmails.js' },
260
+ { name: 'update-check', every: '12h', callback: './server/utils/updates/runUpdateCheck.js' },
261
+ ] })],
262
+ ]){
263
+ const [error] = await run();
264
+ if(error) console.error(`[kempo] System ${step}: ${error.msg}`);
265
+ }
266
+ })();
197
267
  const PROJECT_PUBLIC = config.rootPath || join(process.cwd(), 'public');
198
268
  const ADMIN_ROOT = resolveAdminRoot(config.adminRoot);
199
269
 
@@ -206,6 +276,7 @@ export default config => {
206
276
 
207
277
  return async (request, response, next) => {
208
278
  await encryptionKeyReady;
279
+ await systemSettingsReady;
209
280
  const { path } = request;
210
281
  const url = request.url.split('?')[0];
211
282
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.7.0",
3
+ "version": "4.9.0",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -33,6 +33,8 @@
33
33
  "server/utils",
34
34
  "server/db/index.js",
35
35
  "server/db/schema.js",
36
+ "server/db/upgrades",
37
+ "docker",
36
38
  "server/sdk.js",
37
39
  "middleware/kempo.js",
38
40
  "app-templates",
@@ -54,8 +56,7 @@
54
56
  "inquirer": "^14.0.2",
55
57
  "lexical": "0.43.0",
56
58
  "monaco-editor": "^0.56.0",
57
- "postgres": "^3.4.7",
58
- "resend": "^6.5.2"
59
+ "postgres": "^3.4.7"
59
60
  },
60
61
  "peerDependencies": {
61
62
  "kempo-css": ">=2.2.5",
@@ -115,6 +115,18 @@ export const extension = pgTable('extension', {
115
115
  updatedAt: timestamp('updatedAt').notNull(),
116
116
  });
117
117
 
118
+ /*
119
+ Which of kempo's own data migrations (server/db/upgrades/*.js) have run on this database. The additive changes to its
120
+ tables need no record, they are found by comparing; a migration that restructures data runs once and is recorded here.
121
+ The row with id 'baseline' marks the database as already holding what the shipped migrations produce.
122
+ */
123
+ export const upgrade = pgTable('upgrade', {
124
+ id: text('id').primaryKey(),
125
+ description: text('description'),
126
+ breaking: boolean('breaking').notNull().default(false),
127
+ appliedAt: timestamp('appliedAt').notNull().defaultNow(),
128
+ });
129
+
118
130
  /*
119
131
  Scheduled jobs: recurring work an extension declares in its kempo-config.json `jobs`. A row per job
120
132
  is the schedule and the lease: `nextRunAt` is when it is next due and `lockedUntil` is held while a
@@ -138,6 +150,38 @@ export const scheduledJob = pgTable('scheduledJob', {
138
150
  index('scheduledJob_next_idx').on(table.nextRunAt),
139
151
  ]);
140
152
 
153
+ /*
154
+ Email
155
+
156
+ Every email kempo is asked to send is a row here before anything is sent, so there is always a record of what was
157
+ sent, to whom, and what happened. Kempo itself sends nothing: it fires the `email:send` hook and an email extension
158
+ (kempo-resend, kempo-smtp...) sends it.
159
+
160
+ `status` is `queued` (waiting for a sender, or for a retry), `sending` (a sender is working on it now), `sent` or
161
+ `failed` (no attempts left). `type` is the template it was made from (`verify-email`, `kempo-commerce/order-processing`)
162
+ or `other`, and is what the retention settings are keyed by. `html` is the whole email, which can hold links that
163
+ sign someone in or set a password, so reading the log needs its own permission.
164
+ */
165
+ export const email = pgTable('email', {
166
+ id: text('id').primaryKey(),
167
+ type: text('type').notNull().default('other'),
168
+ recipient: text('recipient').notNull(),
169
+ sender: text('sender').notNull(),
170
+ subject: text('subject').notNull(),
171
+ html: text('html').notNull(),
172
+ status: text('status').notNull().default('queued'),
173
+ attempts: integer('attempts').notNull().default(0),
174
+ lastError: text('lastError'),
175
+ provider: text('provider'),
176
+ providerRef: text('providerRef'),
177
+ createdAt: timestamp('createdAt').notNull(),
178
+ updatedAt: timestamp('updatedAt').notNull(),
179
+ sentAt: timestamp('sentAt'),
180
+ }, table => [
181
+ index('email_status_idx').on(table.status, table.updatedAt),
182
+ index('email_createdAt_idx').on(table.createdAt),
183
+ ]);
184
+
141
185
  /*
142
186
  Realtime System
143
187
  */
@@ -0,0 +1,20 @@
1
+ # Data migrations
2
+
3
+ Additive changes to kempo's tables (new tables, columns, indexes) need nothing here: they are applied when the server starts.
4
+ A change that restructures or moves data goes in a file in this folder, named so that they sort in the order they must run:
5
+
6
+ 4.9.0-split-user-name.js
7
+
8
+ ```js
9
+ export const meta = { description: 'Splits user.name into first and last', breaking: true };
10
+
11
+ export const up = async ({ db, sql }) => {
12
+ await db.execute(sql`...`);
13
+ };
14
+ ```
15
+
16
+ - It runs once, inside a transaction, when the first server of the new version starts, and is recorded in the `upgrade` table.
17
+ - `breaking: true` is for anything the previous version cannot run against (data moved or removed). The Updates screen warns
18
+ before such an update, kempo backs the database up first, and a failure puts the previous version and that backup back.
19
+ - Add one only in a release whose version is higher than the first one that has this folder: a site's `upgrade` table starts with
20
+ every file that was here when it first started recorded as already run.
package/server/sdk.js CHANGED
@@ -186,6 +186,11 @@ export { startScheduler, stopScheduler } from './utils/scheduler/scheduler.js';
186
186
  export { default as sendEmail } from './utils/email/sendEmail.js';
187
187
  export { default as sendEmailFromTemplate } from './utils/email/sendEmailFromTemplate.js';
188
188
  export { default as renderEmailTemplate } from './utils/email/renderEmailTemplate.js';
189
+ export { default as getSenderEmail } from './utils/email/getSenderEmail.js';
190
+ export { default as listEmails } from './utils/email/listEmails.js';
191
+ export { default as getEmail } from './utils/email/getEmail.js';
192
+ export { default as retryEmail } from './utils/email/retryEmail.js';
193
+ export { default as pruneEmails } from './utils/email/pruneEmails.js';
189
194
 
190
195
  /*
191
196
  Filesystem Utilities
@@ -216,6 +221,9 @@ export { default as installExtension } from './utils/extensions/installExtension
216
221
  export { default as listExtensions } from './utils/extensions/listExtensions.js';
217
222
  export { default as uninstallExtension } from './utils/extensions/uninstallExtension.js';
218
223
  export { default as updateExtension } from './utils/extensions/updateExtension.js';
224
+ export { default as upgradeExtension } from './utils/extensions/upgradeExtension.js';
225
+ export { default as reconcileExtensions } from './utils/extensions/reconcileExtensions.js';
226
+ export { default as writeSiteFile } from './utils/extensions/writeSiteFile.js';
219
227
 
220
228
  /*
221
229
  Notifications
@@ -0,0 +1,2 @@
1
+ /* The sender kempo uses until the site sets its own. Providers refuse it, so an email extension that knows a better one (the mailbox it signs in with) can use that instead, see `fromIsDefault`. */
2
+ export default 'noreply@example.com';
@@ -0,0 +1,56 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, eq, sql } from 'drizzle-orm';
4
+ import triggerHook from '../hooks/triggerHook.js';
5
+ import emailSettings from './emailSettings.js';
6
+ import defaultSender from './defaultSender.js';
7
+
8
+ export const SEND_HOOK = 'email:send';
9
+
10
+ /*
11
+ One try at sending a queued email.
12
+
13
+ The email is taken (queued -> sending, one conditional UPDATE, so of several processes only one sends it) and the `email:send`
14
+ hook is fired with `{ email }`. Each email extension answers with what it did, and the first to say it sent it ends the chain, so
15
+ with several installed only one sends and a failed one hands the email to the next:
16
+
17
+ export default async ({ email }) => ({ sent: true, provider: 'my-sender', messageId: 'abc' }); // it went
18
+ export default async ({ email }) => ({ sent: false, error: 'Mailbox full' }); // it tried and could not (throwing says the same)
19
+ export default async ({ email }) => ({ sent: false, skipped: true, error: 'No API key is saved' }); // it is not set up, so it did not try
20
+
21
+ `email` is `{ id, to, from, fromIsDefault, subject, html, type, attempt }`; `fromIsDefault` says the site has not set its own sender, so
22
+ `from` is only a placeholder and a sender that knows a better one (the mailbox it signs in with) can use that. A handler sends the email and returns; it never touches the log, which
23
+ kempo keeps. After the chain the email is `sent`, or goes back to `queued` for a retry, or is `failed` when it has no tries left
24
+ (the first try plus `email_max_retries`). Resolves like sendEmail.
25
+ */
26
+ const finish = async (id, changes) => {
27
+ const [row] = await db.update(email).set({ ...changes, updatedAt: new Date() }).where(eq(email.id, id)).returning();
28
+ return row;
29
+ };
30
+
31
+ export default async id => {
32
+ const [claimed] = await db.update(email)
33
+ .set({ status: 'sending', attempts: sql`${email.attempts} + 1`, updatedAt: new Date() })
34
+ .where(and(eq(email.id, id), eq(email.status, 'queued')))
35
+ .returning();
36
+ if(!claimed) return [{ code: 409, msg: 'That email is not waiting to be sent', emailId: id }, null];
37
+
38
+ const [hookError, fired] = await triggerHook(SEND_HOOK, { email: { id: claimed.id, to: claimed.recipient, from: claimed.sender, fromIsDefault: claimed.sender === defaultSender, subject: claimed.subject, html: claimed.html, type: claimed.type, attempt: claimed.attempts } }, { stopWhen: result => result?.sent === true });
39
+ const results = fired?.results ?? [];
40
+ const sent = results.find(entry => entry.result?.sent === true);
41
+
42
+ if(sent){
43
+ await finish(id, { status: 'sent', sentAt: new Date(), lastError: null, provider: String(sent.result.provider ?? sent.owner).slice(0, 100), providerRef: sent.result.messageId ? String(sent.result.messageId).slice(0, 200) : null });
44
+ return [null, { id, status: 'sent', provider: sent.result.provider ?? sent.owner }];
45
+ }
46
+
47
+ /* An extension that is not set up says so and is not counted as having tried: its reason is only shown when nothing else did. */
48
+ const said = entry => `${entry.owner}: ${entry.error ?? entry.result?.error ?? 'did not send it'}`;
49
+ const tried = results.filter(entry => !entry.result?.skipped);
50
+ const reasons = hookError ? [hookError.msg] : (tried.length ? tried : results).map(said);
51
+ const message = (tried.length || hookError ? reasons.join('; ') : results.length ? `No email extension is set up yet. ${reasons.join('; ')}` : '') || 'No email extension is installed to send email (install kempo-resend or kempo-smtp)';
52
+ const { maxRetries } = await emailSettings();
53
+ const final = claimed.attempts >= 1 + maxRetries;
54
+ await finish(id, { status: final ? 'failed' : 'queued', lastError: message.slice(0, 1000) });
55
+ return [{ code: tried.length ? 502 : 503, msg: message, emailId: id }, null];
56
+ };
@@ -0,0 +1,21 @@
1
+ import getSetting from '../settings/getSetting.js';
2
+
3
+ const DEFAULT_RETRIES = 1;
4
+ const DEFAULT_RETENTION_DAYS = 30;
5
+
6
+ const whole = (value, fallback) => (Number.isInteger(Number(value)) && value !== '' && value !== null && Number(value) >= 0 ? Number(value) : fallback);
7
+
8
+ /*
9
+ What the email settings say, with a safe value for anything unreadable: `maxRetries` (tries after the first), `retentionDays` (0 keeps
10
+ forever) and `retentionByType` ({ type: days }), which wins over `retentionDays` for that kind of email.
11
+ */
12
+ export default async () => {
13
+ const [, retries] = await getSetting('system', 'email_max_retries', DEFAULT_RETRIES);
14
+ const [, days] = await getSetting('system', 'email_retention_days', DEFAULT_RETENTION_DAYS);
15
+ const [, byType] = await getSetting('system', 'email_retention_by_type', {});
16
+ const retentionByType = {};
17
+ if(byType && typeof byType === 'object' && !Array.isArray(byType)){
18
+ for(const [type, value] of Object.entries(byType)) if(whole(value, null) !== null) retentionByType[type] = Number(value);
19
+ }
20
+ return { maxRetries: whole(retries, DEFAULT_RETRIES), retentionDays: whole(days, DEFAULT_RETENTION_DAYS), retentionByType };
21
+ };
@@ -0,0 +1,36 @@
1
+ import { join, dirname } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+ const PART = '[a-z0-9][a-z0-9-]*';
6
+ const NAME = new RegExp(`^(?:(${PART})/)?(${PART})$`);
7
+
8
+ /*
9
+ Where an email template lives, the same places renderEmailTemplate looks:
10
+
11
+ "owner/name" the extension's own `node_modules/owner/emails/name.page.html`, which the site overrides at
12
+ `templates/emails/owner/name.page.html`
13
+ "name" kempo's own `app-templates/emails/name.page.html`, which the site overrides at
14
+ `templates/emails/name.page.html`
15
+ */
16
+ export const parseTemplateName = template => {
17
+ const match = typeof template === 'string' ? NAME.exec(template) : null;
18
+ return match ? { owner: match[1] ?? null, name: match[2] } : null;
19
+ };
20
+
21
+ export const siteEmailsDir = owner => (owner ? join(process.cwd(), 'templates/emails', owner) : join(process.cwd(), 'templates/emails'));
22
+
23
+ export const defaultEmailsDir = owner => (owner ? join(process.cwd(), 'node_modules', owner, 'emails') : join(here, '../../../app-templates/emails'));
24
+
25
+ export const locateTemplate = template => {
26
+ const parsed = parseTemplateName(template);
27
+ if(!parsed) return null;
28
+ return { ...parsed, template, sitePath: join(siteEmailsDir(parsed.owner), `${parsed.name}.page.html`), defaultPath: join(defaultEmailsDir(parsed.owner), `${parsed.name}.page.html`) };
29
+ };
30
+
31
+ export const isOwnerName = owner => new RegExp(`^${PART}$`).test(owner);
32
+
33
+ /* The page's `title`, which is what a list shows as the email's name. */
34
+ export const titleOf = source => /<page\b[^>]*\stitle="([^"]*)"/.exec(source)?.[1] ?? '';
35
+
36
+ export const sameSource = (a, b) => String(a).replace(/\r\n/g, '\n').trim() === String(b).replace(/\r\n/g, '\n').trim();
@@ -0,0 +1,14 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { eq } from 'drizzle-orm';
4
+
5
+ /* One email of the log, with its whole body. Resolves to [error, email]. */
6
+ export default async id => {
7
+ if(!id) return [{ code: 400, msg: 'An email id is required' }, null];
8
+ try {
9
+ const [row] = await db.select().from(email).where(eq(email.id, String(id))).limit(1);
10
+ return row ? [null, row] : [{ code: 404, msg: 'Email not found' }, null];
11
+ } catch {
12
+ return [{ code: 500, msg: 'Failed to read the email' }, null];
13
+ }
14
+ };
@@ -0,0 +1,11 @@
1
+ import getSenderFrom from './getSenderFrom.js';
2
+
3
+ /*
4
+ The bare address kempo sends its emails from (`shop@example.com`, out of `Shop <shop@example.com>`), for an extension that needs
5
+ one for the site, such as a shipping label's sender. It is the same address emails go out from (see getSenderFrom), so it is a
6
+ placeholder (noreply@example.com) until the site sets one. Resolves to the address, or null if there is none to find.
7
+ */
8
+ export default async () => {
9
+ const from = (await getSenderFrom()).trim();
10
+ return (/<([^<>\s]+@[^<>\s]+)>/.exec(from) ?? /^([^<>\s]+@[^<>\s]+)$/.exec(from))?.[1] ?? null;
11
+ };
@@ -0,0 +1,18 @@
1
+ import getSetting from '../settings/getSetting.js';
2
+ import isValidSender from './isValidSender.js';
3
+ import PLACEHOLDER from './defaultSender.js';
4
+
5
+ /*
6
+ What kempo sends its emails from, as the email provider takes it. In order:
7
+
8
+ the `system:email_from` setting edited on the admin Settings screen (it must be a valid address, or empty)
9
+ `EMAIL_FROM` the server environment; what the README and `kempo init` write
10
+ `SMTP_FROM` what the environment used to be called
11
+ a placeholder address (noreply@example.com), which most providers will refuse until the site sets its own
12
+
13
+ A value that is not an address is skipped rather than sent, so a bad environment value cannot stop every email.
14
+ */
15
+ export default async () => {
16
+ const [, stored] = await getSetting('system', 'email_from', '');
17
+ return [stored, process.env.EMAIL_FROM, process.env.SMTP_FROM].map(value => String(value ?? '').trim()).find(isValidSender) ?? PLACEHOLDER;
18
+ };
@@ -0,0 +1,4 @@
1
+ /* Whether text can be the address emails are sent from: `name@example.com` or `Shop Name <name@example.com>`, on one line. */
2
+ export default value => typeof value === 'string'
3
+ && value.length <= 200
4
+ && /^(?:[^<>\r\n@]+<[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+>|[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+)$/.test(value.trim());
@@ -0,0 +1,23 @@
1
+ import { readdir } from 'fs/promises';
2
+ import { defaultEmailsDir, isOwnerName, siteEmailsDir } from './emailTemplatePaths.js';
3
+ import readEmailTemplate from './readEmailTemplate.js';
4
+
5
+ const SUFFIX = '.page.html';
6
+
7
+ const namesIn = async dir => (await readdir(dir).catch(() => [])).filter(file => file.endsWith(SUFFIX)).map(file => file.slice(0, -SUFFIX.length));
8
+
9
+ /*
10
+ The email templates of one extension (`owner`), or kempo's own and the site's (no owner): the packaged ones, and any the site has
11
+ added. Resolves to [error, [{ template, name, title, overridden, packaged }]] sorted by name.
12
+ */
13
+ export default async ({ owner = null } = {}) => {
14
+ if(owner !== null && !isOwnerName(owner)) return [{ code: 400, msg: 'Invalid extension name' }, null];
15
+
16
+ const names = [...new Set([...await namesIn(defaultEmailsDir(owner)), ...await namesIn(siteEmailsDir(owner))])].sort();
17
+ const templates = [];
18
+ for(const name of names){
19
+ const [error, found] = await readEmailTemplate({ template: owner ? `${owner}/${name}` : name });
20
+ if(!error) templates.push({ template: found.template, name, title: found.title, overridden: found.overridden, packaged: found.defaultSource !== null });
21
+ }
22
+ return [null, templates];
23
+ };
@@ -0,0 +1,35 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
4
+
5
+ const STATUSES = ['queued', 'sending', 'sent', 'failed'];
6
+
7
+ /*
8
+ The log, newest first, without the bodies (read one with getEmail). `status`, `type` and `search` (a part of the recipient or the
9
+ subject) narrow it. Resolves to [error, { emails, total, limit, offset }].
10
+ */
11
+ export default async ({ status, type, search, limit = 50, offset = 0 } = {}) => {
12
+ try {
13
+ const conditions = [];
14
+ if(status){
15
+ if(!STATUSES.includes(status)) return [{ code: 400, msg: `Status must be one of ${STATUSES.join(', ')}` }, null];
16
+ conditions.push(eq(email.status, status));
17
+ }
18
+ if(type) conditions.push(eq(email.type, String(type)));
19
+ if(search){
20
+ const like = `%${String(search).replace(/[\\%_]/g, '\\$&')}%`;
21
+ conditions.push(or(ilike(email.recipient, like), ilike(email.subject, like)));
22
+ }
23
+ const where = conditions.length ? and(...conditions) : undefined;
24
+ const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
25
+ const skip = Math.max(Number(offset) || 0, 0);
26
+ const emails = await db.select({
27
+ id: email.id, type: email.type, recipient: email.recipient, sender: email.sender, subject: email.subject, status: email.status, attempts: email.attempts,
28
+ lastError: email.lastError, provider: email.provider, createdAt: email.createdAt, updatedAt: email.updatedAt, sentAt: email.sentAt,
29
+ }).from(email).where(where).orderBy(desc(email.createdAt)).limit(take).offset(skip);
30
+ const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(email).where(where);
31
+ return [null, { emails, total, limit: take, offset: skip }];
32
+ } catch {
33
+ return [{ code: 500, msg: 'Failed to read the email log' }, null];
34
+ }
35
+ };
@@ -0,0 +1,46 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, asc, eq, lt, or, gt, sql } from 'drizzle-orm';
4
+ import dispatchEmail from './dispatchEmail.js';
5
+ import emailSettings from './emailSettings.js';
6
+ import pruneEmails from './pruneEmails.js';
7
+
8
+ const MINUTE_MS = 60 * 1000;
9
+ const STUCK_MS = 5 * MINUTE_MS;
10
+ const BACKOFF_MS = MINUTE_MS;
11
+ const TOO_OLD_MS = 24 * 60 * MINUTE_MS;
12
+ const BATCH = 50;
13
+
14
+ /*
15
+ The email job, run every minute by the scheduler (kempo registers it itself):
16
+
17
+ - a `sending` email that has not finished in five minutes (the process died) goes back to `queued`;
18
+ - a `queued` email with tries left is sent again, a minute after its last try at the earliest, up to 50 at a time;
19
+ - a `queued` email older than a day is marked `failed` instead, so a password-reset link is never sent a day late;
20
+ - emails older than the retention settings are deleted.
21
+ */
22
+ export default async () => {
23
+ const now = Date.now();
24
+ const { maxRetries } = await emailSettings();
25
+
26
+ await db.update(email)
27
+ .set({ status: 'queued', lastError: 'The send did not finish' })
28
+ .where(and(eq(email.status, 'sending'), lt(email.updatedAt, new Date(now - STUCK_MS))));
29
+
30
+ await db.update(email)
31
+ .set({ status: 'failed', lastError: sql`coalesce(${email.lastError}, 'Never sent') || ' (too old to send now)'`, updatedAt: new Date() })
32
+ .where(and(eq(email.status, 'queued'), lt(email.createdAt, new Date(now - TOO_OLD_MS))));
33
+
34
+ const due = await db.select({ id: email.id }).from(email)
35
+ .where(and(
36
+ eq(email.status, 'queued'),
37
+ gt(email.createdAt, new Date(now - TOO_OLD_MS)),
38
+ lt(email.attempts, 1 + maxRetries),
39
+ or(eq(email.attempts, 0), lt(email.updatedAt, new Date(now - BACKOFF_MS))),
40
+ ))
41
+ .orderBy(asc(email.createdAt))
42
+ .limit(BATCH);
43
+ for(const { id } of due) await dispatchEmail(id);
44
+
45
+ await pruneEmails();
46
+ };
@@ -0,0 +1,29 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, lt, eq, notInArray } from 'drizzle-orm';
4
+ import emailSettings from './emailSettings.js';
5
+
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+
8
+ /*
9
+ Deletes the emails that are older than the settings keep them. A kind of email in `email_retention_by_type` is kept that many days,
10
+ every other kind `email_retention_days`; 0 keeps it forever. Resolves to [error, { deleted }].
11
+ */
12
+ export default async ({ now = new Date() } = {}) => {
13
+ try {
14
+ const { retentionDays, retentionByType } = await emailSettings();
15
+ const specific = Object.keys(retentionByType);
16
+ let deleted = 0;
17
+ for(const [type, days] of Object.entries(retentionByType)){
18
+ if(days === 0) continue;
19
+ deleted += (await db.delete(email).where(and(eq(email.type, type), lt(email.createdAt, new Date(now.getTime() - days * DAY_MS)))).returning({ id: email.id })).length;
20
+ }
21
+ if(retentionDays > 0){
22
+ const older = lt(email.createdAt, new Date(now.getTime() - retentionDays * DAY_MS));
23
+ deleted += (await db.delete(email).where(specific.length ? and(notInArray(email.type, specific), older) : older).returning({ id: email.id })).length;
24
+ }
25
+ return [null, { deleted }];
26
+ } catch {
27
+ return [{ code: 500, msg: 'Failed to delete old emails' }, null];
28
+ }
29
+ };