@technomoron/mail-magic 1.0.16 → 1.0.23

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/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { FormAPI } from './api/forms.js';
4
4
  import { MailerAPI } from './api/mailer.js';
5
5
  import { mailApiServer } from './server.js';
6
6
  import { mailStore } from './store/store.js';
7
+ const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
7
8
  function normalizeRoute(value, fallback = '') {
8
9
  if (!value) {
9
10
  return fallback;
@@ -18,12 +19,20 @@ function normalizeRoute(value, fallback = '') {
18
19
  }
19
20
  return withLeading.replace(/\/+$/, '');
20
21
  }
22
+ function mergeStaticDirs(base, override) {
23
+ const merged = { ...base, ...(override ?? {}) };
24
+ if (Object.keys(merged).length === 0) {
25
+ return undefined;
26
+ }
27
+ return merged;
28
+ }
21
29
  function buildServerConfig(store, overrides) {
22
30
  const env = store.env;
23
31
  return {
24
32
  apiHost: env.API_HOST,
25
33
  apiPort: env.API_PORT,
26
34
  uploadPath: store.getUploadStagingPath(),
35
+ uploadMax: env.UPLOAD_MAX,
27
36
  debug: env.DEBUG,
28
37
  apiBasePath: normalizeRoute(env.API_BASE_PATH, '/api'),
29
38
  swaggerEnabled: env.SWAGGER_ENABLED,
@@ -36,11 +45,40 @@ export async function createMailMagicServer(overrides = {}) {
36
45
  if (typeof overrides.apiBasePath === 'string') {
37
46
  store.env.API_BASE_PATH = overrides.apiBasePath;
38
47
  }
39
- const config = buildServerConfig(store, overrides);
48
+ const baseStaticDirs = {};
49
+ let adminUiPath = null;
50
+ if (store.env.ADMIN_ENABLED) {
51
+ adminUiPath = await resolveAdminUiPath(store);
52
+ if (adminUiPath) {
53
+ baseStaticDirs['/'] = adminUiPath;
54
+ }
55
+ }
56
+ const mergedOverrides = {
57
+ ...overrides,
58
+ staticDirs: mergeStaticDirs(baseStaticDirs, overrides.staticDirs)
59
+ };
60
+ const config = buildServerConfig(store, mergedOverrides);
40
61
  const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
41
- mountAssetRoute(server, store);
62
+ // Serve domain assets from a public route with traversal protection and caching.
63
+ const assetRoute = normalizeRoute(store.env.ASSET_ROUTE, '/asset');
64
+ const assetPrefix = assetRoute === '/' ? '' : assetRoute;
65
+ const apiBasePath = normalizeRoute(store.env.API_BASE_PATH, '/api');
66
+ const apiBasePrefix = apiBasePath === '/' ? '' : apiBasePath;
67
+ const assetHandler = createAssetHandler(server);
68
+ const assetMounts = new Set();
69
+ assetMounts.add(assetPrefix);
70
+ // Integration tests (and API_URL defaults) expect assets to also be reachable under the API base path.
71
+ if (apiBasePrefix && assetPrefix && !assetPrefix.startsWith(`${apiBasePrefix}/`)) {
72
+ assetMounts.add(`${apiBasePrefix}${assetPrefix}`);
73
+ }
74
+ for (const prefix of assetMounts) {
75
+ // Express 5 (path-to-regexp v8) requires wildcard params to be named.
76
+ // Use ApiServer.useExpress() so mounts under `apiBasePath` are installed on the API router
77
+ // (and remain reachable before the API 404 handler).
78
+ server.useExpress(`${prefix}/:domain/*path`, assetHandler);
79
+ }
42
80
  if (store.env.ADMIN_ENABLED) {
43
- await enableAdminFeatures(server, store);
81
+ await enableAdminFeatures(server, store, adminUiPath);
44
82
  }
45
83
  else {
46
84
  store.print_debug('Admin UI/API disabled via ADMIN_ENABLED');
@@ -76,15 +114,28 @@ const isDirectExecution = (() => {
76
114
  if (isDirectExecution) {
77
115
  void bootMailMagic();
78
116
  }
79
- async function enableAdminFeatures(server, store) {
117
+ async function resolveAdminUiPath(store) {
118
+ try {
119
+ const mod = (await import('@technomoron/mail-magic-admin'));
120
+ if (typeof mod?.resolveAdminDist === 'function') {
121
+ return mod.resolveAdminDist(store.env.ADMIN_APP_PATH, (message) => store.print_debug(message));
122
+ }
123
+ }
124
+ catch (err) {
125
+ store.print_debug(`Unable to resolve admin UI path: ${err instanceof Error ? err.message : String(err)}`);
126
+ }
127
+ return null;
128
+ }
129
+ async function enableAdminFeatures(server, store, adminUiPath) {
80
130
  try {
81
131
  const mod = (await import('@technomoron/mail-magic-admin'));
82
132
  if (typeof mod?.registerAdmin === 'function') {
83
133
  await mod.registerAdmin(server, {
84
134
  apiBasePath: normalizeRoute(store.env.API_BASE_PATH, '/api'),
85
135
  assetRoute: normalizeRoute(store.env.ASSET_ROUTE, '/asset'),
86
- appPath: store.env.ADMIN_APP_PATH,
87
- logger: (message) => store.print_debug(message)
136
+ appPath: adminUiPath ?? store.env.ADMIN_APP_PATH,
137
+ logger: (message) => store.print_debug(message),
138
+ staticFallback: Boolean(adminUiPath)
88
139
  });
89
140
  }
90
141
  else if (mod?.AdminAPI) {
@@ -98,22 +149,3 @@ async function enableAdminFeatures(server, store) {
98
149
  store.print_debug(`Unable to load admin module: ${err instanceof Error ? err.message : String(err)}`);
99
150
  }
100
151
  }
101
- function mountAssetRoute(server, store) {
102
- const normalizedRoute = normalizeRoute(store.env.ASSET_ROUTE, '/asset');
103
- server.app.get(`${normalizedRoute}/:domain/*`, createAssetHandler(server));
104
- ensureApiNotFoundLast(server);
105
- }
106
- function ensureApiNotFoundLast(server) {
107
- const anyServer = server;
108
- const handler = anyServer.apiNotFoundHandler;
109
- const stack = anyServer.app?._router?.stack;
110
- if (!handler || !Array.isArray(stack)) {
111
- return;
112
- }
113
- const index = stack.findIndex((layer) => layer?.handle === handler);
114
- if (index === -1 || index === stack.length - 1) {
115
- return;
116
- }
117
- const [layer] = stack.splice(index, 1);
118
- stack.push(layer);
119
- }
package/dist/models/db.js CHANGED
@@ -2,13 +2,15 @@ import { Sequelize } from 'sequelize';
2
2
  import { init_api_domain, api_domain } from './domain.js';
3
3
  import { init_api_form, api_form } from './form.js';
4
4
  import { importData } from './init.js';
5
+ import { init_api_recipient, api_recipient } from './recipient.js';
5
6
  import { init_api_txmail, api_txmail } from './txmail.js';
6
- import { init_api_user, api_user } from './user.js';
7
+ import { init_api_user, api_user, migrateLegacyApiTokens } from './user.js';
7
8
  export async function init_api_db(db, store) {
8
9
  await init_api_user(db);
9
10
  await init_api_domain(db);
10
11
  await init_api_txmail(db);
11
12
  await init_api_form(db);
13
+ await init_api_recipient(db);
12
14
  // User ↔ Domain
13
15
  api_user.hasMany(api_domain, {
14
16
  foreignKey: 'user_id',
@@ -60,11 +62,29 @@ export async function init_api_db(db, store) {
60
62
  foreignKey: 'domain_id',
61
63
  as: 'domain'
62
64
  });
65
+ // Domain ↔ Recipient (form recipient allowlist)
66
+ api_domain.hasMany(api_recipient, {
67
+ foreignKey: 'domain_id',
68
+ as: 'recipients'
69
+ });
70
+ api_recipient.belongsTo(api_domain, {
71
+ foreignKey: 'domain_id',
72
+ as: 'domain'
73
+ });
63
74
  await db.query('PRAGMA foreign_keys = OFF');
64
75
  store.print_debug(`Force alter tables: ${store.env.DB_FORCE_SYNC}`);
65
76
  await db.sync({ alter: true, force: store.env.DB_FORCE_SYNC });
66
77
  await db.query('PRAGMA foreign_keys = ON');
67
78
  await importData(store);
79
+ try {
80
+ const { migrated, cleared } = await migrateLegacyApiTokens(store.env.API_TOKEN_PEPPER);
81
+ if (migrated || cleared) {
82
+ store.print_debug(`Migrated ${migrated} legacy API token(s) and cleared ${cleared} plaintext token(s).`);
83
+ }
84
+ }
85
+ catch (err) {
86
+ store.print_debug(`Failed to migrate legacy API tokens: ${err instanceof Error ? err.message : String(err)}`);
87
+ }
68
88
  store.print_debug('API Database Initialized...');
69
89
  }
70
90
  export async function connect_api_db(store) {
@@ -90,7 +110,14 @@ export async function connect_api_db(store) {
90
110
  dbparams.username = env.DB_USER;
91
111
  dbparams.password = env.DB_PASS;
92
112
  }
93
- store.print_debug(`Database params are:\n${JSON.stringify(dbparams, undefined, 2)}`);
113
+ const debugDbParams = { ...dbparams };
114
+ if (typeof debugDbParams.password === 'string' && debugDbParams.password) {
115
+ debugDbParams.password = '<redacted>';
116
+ }
117
+ if (typeof debugDbParams.username === 'string' && debugDbParams.username) {
118
+ debugDbParams.username = '<redacted>';
119
+ }
120
+ store.print_debug(`Database params are:\n${JSON.stringify(debugDbParams, undefined, 2)}`);
94
121
  const db = new Sequelize(dbparams);
95
122
  await db.authenticate();
96
123
  store.print_debug('API Database Connected');
@@ -1,9 +1,11 @@
1
1
  import path from 'path';
2
+ import { nanoid } from 'nanoid';
2
3
  import { Model, DataTypes } from 'sequelize';
3
4
  import { z } from 'zod';
4
5
  import { user_and_domain, normalizeSlug } from '../util.js';
5
6
  export const api_form_schema = z.object({
6
7
  form_id: z.number().int().nonnegative(),
8
+ form_key: z.string().min(1).nullable().optional(),
7
9
  user_id: z.number().int().nonnegative(),
8
10
  domain_id: z.number().int().nonnegative(),
9
11
  locale: z.string().default(''),
@@ -15,6 +17,7 @@ export const api_form_schema = z.object({
15
17
  filename: z.string().default(''),
16
18
  slug: z.string().default(''),
17
19
  secret: z.string().default(''),
20
+ captcha_required: z.boolean().default(false),
18
21
  files: z
19
22
  .array(z.object({
20
23
  filename: z.string(),
@@ -33,6 +36,11 @@ export async function init_api_form(api_db) {
33
36
  allowNull: false,
34
37
  primaryKey: true
35
38
  },
39
+ form_key: {
40
+ type: DataTypes.STRING,
41
+ allowNull: true,
42
+ defaultValue: null
43
+ },
36
44
  user_id: {
37
45
  type: DataTypes.INTEGER,
38
46
  allowNull: false,
@@ -102,6 +110,11 @@ export async function init_api_form(api_db) {
102
110
  allowNull: false,
103
111
  defaultValue: ''
104
112
  },
113
+ captcha_required: {
114
+ type: DataTypes.BOOLEAN,
115
+ allowNull: false,
116
+ defaultValue: false
117
+ },
105
118
  files: {
106
119
  type: DataTypes.TEXT,
107
120
  allowNull: false,
@@ -120,6 +133,10 @@ export async function init_api_form(api_db) {
120
133
  charset: 'utf8mb4',
121
134
  collate: 'utf8mb4_unicode_ci',
122
135
  indexes: [
136
+ {
137
+ unique: true,
138
+ fields: ['form_key']
139
+ },
123
140
  {
124
141
  unique: true,
125
142
  fields: ['user_id', 'domain_id', 'locale', 'idname']
@@ -164,12 +181,16 @@ export async function upsert_form(record) {
164
181
  let instance = null;
165
182
  instance = await api_form.findByPk(record.form_id);
166
183
  if (instance) {
184
+ if (!instance.form_key && !record.form_key) {
185
+ record.form_key = nanoid();
186
+ }
167
187
  await instance.update(record);
168
188
  }
169
189
  else {
170
- console.log('CREATE', JSON.stringify(record, undefined, 2));
190
+ if (!record.form_key) {
191
+ record.form_key = nanoid();
192
+ }
171
193
  instance = await api_form.create(record);
172
- console.log(`INSTANCE IS ${instance}`);
173
194
  }
174
195
  if (!instance) {
175
196
  throw new Error(`Unable to update/create form ${record.form_id}`);
@@ -6,7 +6,7 @@ import { user_and_domain } from '../util.js';
6
6
  import { api_domain, api_domain_schema } from './domain.js';
7
7
  import { api_form_schema, upsert_form } from './form.js';
8
8
  import { api_txmail_schema, upsert_txmail } from './txmail.js';
9
- import { api_user, api_user_schema } from './user.js';
9
+ import { apiTokenToHmac, api_user, api_user_schema } from './user.js';
10
10
  const init_data_schema = z.object({
11
11
  user: z.array(api_user_schema).default([]),
12
12
  domain: z.array(api_domain_schema).default([]),
@@ -143,8 +143,18 @@ export async function importData(store) {
143
143
  if (records.user) {
144
144
  store.print_debug('Creating user records');
145
145
  for (const record of records.user) {
146
- const { domain, ...userWithoutDomain } = record;
147
- await api_user.upsert({ ...userWithoutDomain, domain: null });
146
+ const { domain, token, token_hmac, ...userWithoutDomain } = record;
147
+ let resolvedTokenHmac;
148
+ if (typeof token_hmac === 'string' && token_hmac) {
149
+ resolvedTokenHmac = token_hmac;
150
+ }
151
+ else if (typeof token === 'string' && token) {
152
+ resolvedTokenHmac = apiTokenToHmac(token, store.env.API_TOKEN_PEPPER);
153
+ }
154
+ else {
155
+ throw new Error(`User ${record.user_id} is missing token or token_hmac`);
156
+ }
157
+ await api_user.upsert({ ...userWithoutDomain, token: '', token_hmac: resolvedTokenHmac, domain: null });
148
158
  if (typeof domain === 'number') {
149
159
  pendingUserDomains.push({ user_id: record.user_id, domain });
150
160
  }
@@ -0,0 +1,65 @@
1
+ import { Model, DataTypes } from 'sequelize';
2
+ import { z } from 'zod';
3
+ export const api_recipient_schema = z.object({
4
+ recipient_id: z.number().int().nonnegative(),
5
+ domain_id: z.number().int().nonnegative(),
6
+ // Empty string means "domain-wide"; otherwise scope to a specific form_key.
7
+ form_key: z.string().default(''),
8
+ idname: z.string().min(1),
9
+ email: z.string().min(1),
10
+ name: z.string().default('')
11
+ });
12
+ export class api_recipient extends Model {
13
+ }
14
+ export async function init_api_recipient(api_db) {
15
+ api_recipient.init({
16
+ recipient_id: {
17
+ type: DataTypes.INTEGER,
18
+ autoIncrement: true,
19
+ allowNull: false,
20
+ primaryKey: true
21
+ },
22
+ domain_id: {
23
+ type: DataTypes.INTEGER,
24
+ allowNull: false,
25
+ references: {
26
+ model: 'domain',
27
+ key: 'domain_id'
28
+ },
29
+ onDelete: 'CASCADE',
30
+ onUpdate: 'CASCADE'
31
+ },
32
+ form_key: {
33
+ type: DataTypes.STRING,
34
+ allowNull: false,
35
+ defaultValue: ''
36
+ },
37
+ idname: {
38
+ type: DataTypes.STRING,
39
+ allowNull: false,
40
+ defaultValue: ''
41
+ },
42
+ email: {
43
+ type: DataTypes.STRING,
44
+ allowNull: false,
45
+ defaultValue: ''
46
+ },
47
+ name: {
48
+ type: DataTypes.STRING,
49
+ allowNull: false,
50
+ defaultValue: ''
51
+ }
52
+ }, {
53
+ sequelize: api_db,
54
+ tableName: 'recipient',
55
+ charset: 'utf8mb4',
56
+ collate: 'utf8mb4_unicode_ci',
57
+ indexes: [
58
+ {
59
+ unique: true,
60
+ fields: ['domain_id', 'form_key', 'idname']
61
+ }
62
+ ]
63
+ });
64
+ return api_recipient;
65
+ }
@@ -1,9 +1,11 @@
1
- import { Model, DataTypes } from 'sequelize';
1
+ import { createHmac } from 'node:crypto';
2
+ import { Model, DataTypes, Op } from 'sequelize';
2
3
  import { z } from 'zod';
3
4
  export const api_user_schema = z.object({
4
5
  user_id: z.number().int().nonnegative(),
5
6
  idname: z.string().min(1),
6
- token: z.string().min(1),
7
+ token: z.string().min(1).optional(),
8
+ token_hmac: z.string().min(1).optional(),
7
9
  name: z.string().min(1),
8
10
  email: z.string().email(),
9
11
  domain: z.number().int().nonnegative().nullable().optional(),
@@ -11,6 +13,30 @@ export const api_user_schema = z.object({
11
13
  });
12
14
  export class api_user extends Model {
13
15
  }
16
+ export function apiTokenToHmac(token, pepper) {
17
+ return createHmac('sha256', pepper).update(token).digest('hex');
18
+ }
19
+ export async function migrateLegacyApiTokens(pepper) {
20
+ const users = await api_user.findAll({
21
+ where: {
22
+ token: {
23
+ [Op.ne]: ''
24
+ }
25
+ }
26
+ });
27
+ let migrated = 0;
28
+ let cleared = 0;
29
+ for (const user of users) {
30
+ const updates = { token: '' };
31
+ if (!user.token_hmac && user.token) {
32
+ updates.token_hmac = apiTokenToHmac(user.token, pepper);
33
+ migrated += 1;
34
+ }
35
+ cleared += 1;
36
+ await user.update(updates);
37
+ }
38
+ return { migrated, cleared };
39
+ }
14
40
  export async function init_api_user(api_db) {
15
41
  await api_user.init({
16
42
  user_id: {
@@ -29,6 +55,11 @@ export async function init_api_user(api_db) {
29
55
  allowNull: false,
30
56
  defaultValue: ''
31
57
  },
58
+ token_hmac: {
59
+ type: DataTypes.STRING,
60
+ allowNull: true,
61
+ defaultValue: null
62
+ },
32
63
  name: {
33
64
  type: DataTypes.STRING,
34
65
  allowNull: false,
@@ -59,7 +90,13 @@ export async function init_api_user(api_db) {
59
90
  sequelize: api_db,
60
91
  tableName: 'user',
61
92
  charset: 'utf8mb4',
62
- collate: 'utf8mb4_unicode_ci'
93
+ collate: 'utf8mb4_unicode_ci',
94
+ indexes: [
95
+ {
96
+ unique: true,
97
+ fields: ['token_hmac']
98
+ }
99
+ ]
63
100
  });
64
101
  return api_user;
65
102
  }
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ApiServer } from '@technomoron/api-server-base';
2
- import { api_user } from './models/user.js';
2
+ import { apiTokenToHmac, api_user } from './models/user.js';
3
3
  export class mailApiServer extends ApiServer {
4
4
  store;
5
5
  storage;
@@ -9,14 +9,26 @@ export class mailApiServer extends ApiServer {
9
9
  this.storage = store;
10
10
  }
11
11
  async getApiKey(token) {
12
- this.storage.print_debug(`Looking up api key ${token}`);
13
- const user = await api_user.findOne({ where: { token: token } });
14
- if (!user) {
15
- this.storage.print_debug(`Unable to find user for token ${token}`);
12
+ this.storage.print_debug('Looking up api key');
13
+ const pepper = this.storage.env.API_TOKEN_PEPPER;
14
+ const token_hmac = apiTokenToHmac(token, pepper);
15
+ const user = await api_user.findOne({ where: { token_hmac } });
16
+ if (user) {
17
+ return { uid: user.user_id };
18
+ }
19
+ // Backwards-compatible fallback for legacy databases that still store plaintext tokens.
20
+ const legacy = await api_user.findOne({ where: { token } });
21
+ if (!legacy) {
22
+ this.storage.print_debug('Unable to find user for api key');
16
23
  return null;
17
24
  }
18
- else {
19
- return { uid: user.user_id };
25
+ try {
26
+ await legacy.update({ token_hmac, token: '' });
27
+ }
28
+ catch (err) {
29
+ // Don't leak token data; just surface the update failure for debugging.
30
+ this.storage.print_debug(`Unable to migrate legacy api token: ${err instanceof Error ? err.message : String(err)}`);
20
31
  }
32
+ return { uid: legacy.user_id };
21
33
  }
22
34
  }
@@ -91,6 +91,11 @@ export const envOptions = defineEnvOptions({
91
91
  default: false,
92
92
  type: 'boolean'
93
93
  },
94
+ AUTOESCAPE_HTML: {
95
+ description: 'Enable Nunjucks HTML autoescape when rendering templates',
96
+ default: true,
97
+ type: 'boolean'
98
+ },
94
99
  SMTP_HOST: {
95
100
  description: 'Hostname of SMTP sending host',
96
101
  default: 'localhost'
@@ -121,5 +126,55 @@ export const envOptions = defineEnvOptions({
121
126
  UPLOAD_PATH: {
122
127
  description: 'Path for attached files. Use {domain} to scope per domain.',
123
128
  default: './{domain}/uploads'
129
+ },
130
+ UPLOAD_MAX: {
131
+ description: 'Maximum upload size per file (bytes) when uploads are enabled',
132
+ default: 30 * 1024 * 1024,
133
+ type: 'number'
134
+ },
135
+ FORM_RATE_LIMIT_WINDOW_SEC: {
136
+ description: 'Rate limit window for unauthenticated form submissions (seconds)',
137
+ default: 0,
138
+ type: 'number'
139
+ },
140
+ FORM_RATE_LIMIT_MAX: {
141
+ description: 'Max unauthenticated form submissions per client IP per window (0 disables rate limiting)',
142
+ default: 0,
143
+ type: 'number'
144
+ },
145
+ FORM_MAX_ATTACHMENTS: {
146
+ description: 'Max number of uploaded files accepted by /v1/form/message (-1 unlimited, 0 disables attachments)',
147
+ default: -1,
148
+ type: 'number'
149
+ },
150
+ FORM_KEEP_UPLOADS: {
151
+ description: 'Keep uploaded form files on disk after processing (success or failure)',
152
+ default: true,
153
+ type: 'boolean'
154
+ },
155
+ FORM_CAPTCHA_PROVIDER: {
156
+ description: 'CAPTCHA provider used to verify tokens for /v1/form/message',
157
+ options: ['turnstile', 'hcaptcha', 'recaptcha'],
158
+ default: 'turnstile'
159
+ },
160
+ FORM_CAPTCHA_SECRET: {
161
+ description: 'CAPTCHA secret used by the server to verify tokens (enables captcha checks when set)',
162
+ default: ''
163
+ },
164
+ FORM_CAPTCHA_REQUIRED: {
165
+ description: 'Require a CAPTCHA token for /v1/form/message when captcha is enabled',
166
+ default: false,
167
+ type: 'boolean'
168
+ },
169
+ API_TOKEN_PEPPER: {
170
+ description: 'Server-side pepper used to HMAC API tokens before DB lookup. Keep it stable to preserve existing API keys.',
171
+ required: true,
172
+ transform: (raw) => {
173
+ const value = String(raw ?? '').trim();
174
+ if (value.length < 16) {
175
+ throw new Error('API_TOKEN_PEPPER must be at least 16 characters');
176
+ }
177
+ return value;
178
+ }
124
179
  }
125
180
  });
@@ -113,7 +113,16 @@ export class mailStore {
113
113
  return {};
114
114
  }
115
115
  async init() {
116
- const env = (this.env = await EnvLoader.createConfigProxy(envOptions, { debug: true }));
116
+ // Load env config only via EnvLoader + envOptions (avoid ad-hoc `process.env` parsing here).
117
+ // If DEBUG is enabled, re-load with EnvLoader debug output enabled.
118
+ let env = await EnvLoader.createConfigProxy(envOptions, { debug: false });
119
+ if (env.DEBUG) {
120
+ env = await EnvLoader.createConfigProxy(envOptions, { debug: true });
121
+ }
122
+ this.env = env;
123
+ if (this.env.FORM_CAPTCHA_REQUIRED && !String(this.env.FORM_CAPTCHA_SECRET ?? '').trim()) {
124
+ throw new Error('FORM_CAPTCHA_SECRET must be set when FORM_CAPTCHA_REQUIRED=true');
125
+ }
117
126
  EnvLoader.genTemplate(envOptions, '.env-dist');
118
127
  const p = env.CONFIG_PATH;
119
128
  this.configpath = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
package/dist/util.js CHANGED
@@ -96,22 +96,32 @@ export function decodeComponent(value) {
96
96
  if (!value) {
97
97
  return '';
98
98
  }
99
+ const decoded = Array.isArray(value) ? (value[0] ?? '') : value;
100
+ if (!decoded) {
101
+ return '';
102
+ }
99
103
  try {
100
- return decodeURIComponent(value);
104
+ return decodeURIComponent(decoded);
101
105
  }
102
106
  catch {
103
- return value;
107
+ return decoded;
104
108
  }
105
109
  }
106
- export function sendFileAsync(res, file) {
110
+ export function sendFileAsync(res, file, options) {
107
111
  return new Promise((resolve, reject) => {
108
- res.sendFile(file, (err) => {
112
+ const cb = (err) => {
109
113
  if (err) {
110
- reject(err);
114
+ reject(err instanceof Error ? err : new Error(String(err)));
111
115
  }
112
116
  else {
113
117
  resolve();
114
118
  }
115
- });
119
+ };
120
+ if (options !== undefined) {
121
+ // Express will set Cache-Control based on `maxAge` etc; callers can still override.
122
+ res.sendFile(file, options, cb);
123
+ return;
124
+ }
125
+ res.sendFile(file, cb);
116
126
  });
117
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic",
3
- "version": "1.0.16",
3
+ "version": "1.0.23",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,13 +42,14 @@
42
42
  "url": "https://github.com/technomoron/mail-magic/issues"
43
43
  },
44
44
  "dependencies": {
45
- "@technomoron/api-server-base": "2.0.0-beta.15",
45
+ "@technomoron/api-server-base": "2.0.0-beta.18",
46
46
  "@technomoron/env-loader": "^1.0.8",
47
47
  "@technomoron/unyuck": "^1.0.4",
48
48
  "bcryptjs": "^3.0.2",
49
49
  "dotenv": "^16.4.5",
50
50
  "email-addresses": "^5.0.0",
51
51
  "html-to-text": "^9.0.5",
52
+ "nanoid": "^5.1.6",
52
53
  "nodemailer": "^6.10.1",
53
54
  "nunjucks": "^3.2.4",
54
55
  "sequelize": "^6.37.7",
@@ -58,7 +59,7 @@
58
59
  "zod": "^4.1.5"
59
60
  },
60
61
  "devDependencies": {
61
- "@types/express": "^4.17.21",
62
+ "@types/express": "^5.0.6",
62
63
  "@types/html-to-text": "^9.0.4",
63
64
  "@types/nodemailer": "^6.4.19",
64
65
  "@types/nunjucks": "^3.2.6",