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.
- package/README.md +10 -4
- package/bin/cli.js +38 -34
- package/dist/admin/components/EmailTemplates.js +25 -0
- package/dist/admin/components/EmailTemplates.js.map +1 -0
- package/dist/admin/components/Emails.js +49 -0
- package/dist/admin/components/Emails.js.map +1 -0
- package/dist/admin/components/Updates.js +117 -0
- package/dist/admin/components/Updates.js.map +1 -0
- package/dist/admin/components/settings/AdminSettings.js +7 -0
- package/dist/admin/components/settings/AdminSettings.js.map +1 -0
- package/dist/admin/components/settings/BooleanSetting.js +2 -0
- package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
- package/dist/admin/components/settings/JsonSetting.js +2 -0
- package/dist/admin/components/settings/JsonSetting.js.map +1 -0
- package/dist/admin/components/settings/NumberSetting.js +2 -0
- package/dist/admin/components/settings/NumberSetting.js.map +1 -0
- package/dist/admin/components/settings/SecretSetting.js +2 -0
- package/dist/admin/components/settings/SecretSetting.js.map +1 -0
- package/dist/admin/components/settings/Setting.js +2 -0
- package/dist/admin/components/settings/Setting.js.map +1 -0
- package/dist/admin/components/settings/SettingEditor.js +2 -0
- package/dist/admin/components/settings/SettingEditor.js.map +1 -0
- package/dist/admin/components/settings/SettingsPanel.js +24 -0
- package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
- package/dist/admin/components/settings/StringSetting.js +2 -0
- package/dist/admin/components/settings/StringSetting.js.map +1 -0
- package/dist/admin/components/settings/humanize.js +2 -0
- package/dist/admin/components/settings/humanize.js.map +1 -0
- package/dist/admin/emails/index.page.html +14 -0
- package/dist/admin/icons/mail.svg +1 -0
- package/dist/admin/nav.fragment.html +12 -0
- package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
- package/dist/admin/settings/index.page.html +6 -289
- package/dist/admin/updates/index.page.html +14 -0
- package/dist/kempo/api/email-templates/GET.js +2 -0
- package/dist/kempo/api/email-templates/GET.js.map +1 -0
- package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
- package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
- package/dist/kempo/api/email-templates/file/GET.js +2 -0
- package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
- package/dist/kempo/api/email-templates/file/PUT.js +2 -0
- package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
- package/dist/kempo/api/email-templates/preview/GET.js +2 -0
- package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
- package/dist/kempo/api/emails/GET.js +2 -0
- package/dist/kempo/api/emails/GET.js.map +1 -0
- package/dist/kempo/api/emails/[id]/GET.js +2 -0
- package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
- package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
- package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
- package/dist/kempo/api/emails/test/POST.js +2 -0
- package/dist/kempo/api/emails/test/POST.js.map +1 -0
- package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
- package/dist/kempo/api/settings/[owner]/[name]/POST.js.map +1 -1
- package/dist/kempo/api/updates/GET.js +2 -0
- package/dist/kempo/api/updates/GET.js.map +1 -0
- package/dist/kempo/api/updates/apply/POST.js +2 -0
- package/dist/kempo/api/updates/apply/POST.js.map +1 -0
- package/dist/kempo/api/updates/check/POST.js +2 -0
- package/dist/kempo/api/updates/check/POST.js.map +1 -0
- package/dist/kempo/api/updates/discard/POST.js +2 -0
- package/dist/kempo/api/updates/discard/POST.js.map +1 -0
- package/dist/kempo/api/updates/prepare/POST.js +2 -0
- package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
- package/dist/kempo/api/updates/rollback/POST.js +2 -0
- package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
- package/dist/kempo/fetch.js +1 -1
- package/dist/kempo/fetch.js.map +1 -1
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/docker/.dockerignore +9 -0
- package/docker/Dockerfile +25 -0
- package/docker/docker-compose.prod.yml +39 -0
- package/docker/entrypoint.mjs +96 -0
- package/middleware/kempo.js +71 -0
- package/package.json +4 -3
- package/server/db/schema.js +44 -0
- package/server/db/upgrades/README.md +20 -0
- package/server/sdk.js +8 -0
- package/server/utils/email/defaultSender.js +2 -0
- package/server/utils/email/dispatchEmail.js +56 -0
- package/server/utils/email/emailSettings.js +21 -0
- package/server/utils/email/emailTemplatePaths.js +36 -0
- package/server/utils/email/getEmail.js +14 -0
- package/server/utils/email/getSenderEmail.js +11 -0
- package/server/utils/email/getSenderFrom.js +18 -0
- package/server/utils/email/isValidSender.js +4 -0
- package/server/utils/email/listEmailTemplates.js +23 -0
- package/server/utils/email/listEmails.js +35 -0
- package/server/utils/email/maintainEmails.js +46 -0
- package/server/utils/email/pruneEmails.js +29 -0
- package/server/utils/email/readEmailTemplate.js +28 -0
- package/server/utils/email/resetEmailTemplate.js +15 -0
- package/server/utils/email/retryEmail.js +21 -0
- package/server/utils/email/saveEmailTemplate.js +38 -0
- package/server/utils/email/sendEmail.js +22 -32
- package/server/utils/email/sendEmailFromTemplate.js +2 -2
- package/server/utils/extensions/checkExtensionUpdate.js +9 -36
- package/server/utils/extensions/reconcileExtensions.js +90 -0
- package/server/utils/extensions/updateExtension.js +14 -153
- package/server/utils/extensions/upgradeExtension.js +117 -0
- package/server/utils/extensions/writeSiteFile.js +66 -0
- package/server/utils/hooks/triggerHook.js +17 -4
- package/server/utils/settings/listSettings.js +10 -5
- package/server/utils/settings/setSetting.js +4 -0
- package/server/utils/settings/settingRules.js +21 -0
- package/server/utils/settings/systemSettings.js +41 -0
- package/server/utils/setup/runUpgrades.js +70 -0
- package/server/utils/setup/seedDatabase.js +5 -60
- package/server/utils/setup/seedIfNew.js +21 -0
- package/server/utils/setup/syncCoreSchema.js +130 -0
- package/server/utils/setup/systemPermissions.js +77 -0
- package/server/utils/updates/applyStagedUpdate.js +95 -0
- package/server/utils/updates/applyUpdate.js +76 -0
- package/server/utils/updates/backupDatabase.js +35 -0
- package/server/utils/updates/discardUpdate.js +11 -0
- package/server/utils/updates/extensionTargets.js +101 -0
- package/server/utils/updates/fetchManifest.js +26 -0
- package/server/utils/updates/getExtensionUpdates.js +70 -0
- package/server/utils/updates/getInstallInfo.js +25 -0
- package/server/utils/updates/getUpdateStatus.js +57 -0
- package/server/utils/updates/helpers.js +30 -0
- package/server/utils/updates/notifySchemaAttention.js +35 -0
- package/server/utils/updates/npm.js +41 -0
- package/server/utils/updates/prepareUpdate.js +109 -0
- package/server/utils/updates/releaseStatus.js +23 -0
- package/server/utils/updates/restart.js +64 -0
- package/server/utils/updates/rollbackUpdate.js +56 -0
- package/server/utils/updates/runUpdateCheck.js +52 -0
- package/server/utils/updates/runtime.js +15 -0
- package/server/utils/updates/siteLayout.js +200 -0
- 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)));
|
package/middleware/kempo.js
CHANGED
|
@@ -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.
|
|
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",
|
package/server/db/schema.js
CHANGED
|
@@ -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,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
|
+
};
|