@technomoron/mail-magic 1.0.23 → 1.0.33

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 (41) hide show
  1. package/CHANGES +50 -0
  2. package/README.md +285 -70
  3. package/dist/api/assets.js +9 -56
  4. package/dist/api/auth.js +1 -12
  5. package/dist/api/form-replyto.js +44 -0
  6. package/dist/api/form-submission.js +95 -0
  7. package/dist/api/forms.js +262 -318
  8. package/dist/api/mailer.js +1 -1
  9. package/dist/bin/mail-magic.js +2 -2
  10. package/dist/index.js +30 -19
  11. package/dist/models/db.js +5 -4
  12. package/dist/models/domain.js +17 -8
  13. package/dist/models/form.js +110 -38
  14. package/dist/models/init.js +34 -74
  15. package/dist/models/recipient.js +12 -8
  16. package/dist/models/txmail.js +22 -25
  17. package/dist/models/user.js +14 -10
  18. package/dist/server.js +1 -1
  19. package/dist/store/envloader.js +9 -4
  20. package/dist/store/store.js +53 -22
  21. package/dist/swagger.js +107 -0
  22. package/dist/util/captcha.js +24 -0
  23. package/dist/util/email.js +19 -0
  24. package/dist/util/paths.js +41 -0
  25. package/dist/util/ratelimit.js +48 -0
  26. package/dist/util/uploads.js +48 -0
  27. package/dist/util/utils.js +151 -0
  28. package/dist/util.js +4 -127
  29. package/docs/config-example/example.test/assets/files/banner.png +1 -0
  30. package/docs/config-example/example.test/assets/images/logo.png +1 -0
  31. package/docs/config-example/example.test/form-template/base.njk +6 -0
  32. package/docs/config-example/example.test/form-template/contact.njk +9 -0
  33. package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
  34. package/docs/config-example/example.test/tx-template/base.njk +10 -0
  35. package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
  36. package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
  37. package/docs/config-example/init-data.json +57 -0
  38. package/docs/form-security.md +194 -0
  39. package/docs/swagger/openapi.json +1321 -0
  40. package/{TUTORIAL.MD → docs/tutorial.md} +127 -33
  41. package/package.json +3 -3
@@ -166,7 +166,7 @@ export class MailerAPI extends ApiModule {
166
166
  }
167
167
  }
168
168
  try {
169
- const env = new nunjucks.Environment(null, { autoescape: this.server.storage.env.AUTOESCAPE_HTML });
169
+ const env = new nunjucks.Environment(null, { autoescape: this.server.storage.vars.AUTOESCAPE_HTML });
170
170
  const compiled = nunjucks.compile(template.template, env);
171
171
  for (const recipient of valid) {
172
172
  const fullargs = {
@@ -50,9 +50,9 @@ if (result.error) {
50
50
  }
51
51
  async function main() {
52
52
  try {
53
- const { store, env } = await startMailMagicServer();
53
+ const { store, vars } = await startMailMagicServer();
54
54
  console.log(`Using config path: ${store.configpath}`);
55
- console.log(`mail-magic server listening on ${env.API_HOST}:${env.API_PORT}`);
55
+ console.log(`mail-magic server listening on ${vars.API_HOST}:${vars.API_PORT}`);
56
56
  }
57
57
  catch (error) {
58
58
  console.error('Failed to start mail-magic server');
package/dist/index.js CHANGED
@@ -4,7 +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
+ import { installMailMagicSwagger } from './swagger.js';
8
8
  function normalizeRoute(value, fallback = '') {
9
9
  if (!value) {
10
10
  return fallback;
@@ -27,7 +27,7 @@ function mergeStaticDirs(base, override) {
27
27
  return merged;
28
28
  }
29
29
  function buildServerConfig(store, overrides) {
30
- const env = store.env;
30
+ const env = store.vars;
31
31
  return {
32
32
  apiHost: env.API_HOST,
33
33
  apiPort: env.API_PORT,
@@ -40,14 +40,14 @@ function buildServerConfig(store, overrides) {
40
40
  ...overrides
41
41
  };
42
42
  }
43
- export async function createMailMagicServer(overrides = {}) {
44
- const store = await new mailStore().init();
43
+ export async function createMailMagicServer(overrides = {}, envOverrides = {}) {
44
+ const store = await new mailStore().init(envOverrides);
45
45
  if (typeof overrides.apiBasePath === 'string') {
46
- store.env.API_BASE_PATH = overrides.apiBasePath;
46
+ store.vars.API_BASE_PATH = overrides.apiBasePath;
47
47
  }
48
48
  const baseStaticDirs = {};
49
49
  let adminUiPath = null;
50
- if (store.env.ADMIN_ENABLED) {
50
+ if (store.vars.ADMIN_ENABLED) {
51
51
  adminUiPath = await resolveAdminUiPath(store);
52
52
  if (adminUiPath) {
53
53
  baseStaticDirs['/'] = adminUiPath;
@@ -58,11 +58,22 @@ export async function createMailMagicServer(overrides = {}) {
58
58
  staticDirs: mergeStaticDirs(baseStaticDirs, overrides.staticDirs)
59
59
  };
60
60
  const config = buildServerConfig(store, mergedOverrides);
61
- const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
61
+ // ApiServerBase's built-in swagger handler loads from process.cwd(); install our own handler so
62
+ // SWAGGER_ENABLED works regardless of where the .env lives (mail-magic CLI chdir's to the env dir).
63
+ const { swaggerEnabled, swaggerPath } = config;
64
+ const serverConfig = { ...config, swaggerEnabled: false, swaggerPath: '' };
65
+ const server = new mailApiServer(serverConfig, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
66
+ installMailMagicSwagger(server, {
67
+ apiBasePath: String(config.apiBasePath || '/api'),
68
+ assetRoute: String(store.vars.ASSET_ROUTE || '/asset'),
69
+ apiUrl: String(store.vars.API_URL || ''),
70
+ swaggerEnabled,
71
+ swaggerPath
72
+ });
62
73
  // Serve domain assets from a public route with traversal protection and caching.
63
- const assetRoute = normalizeRoute(store.env.ASSET_ROUTE, '/asset');
74
+ const assetRoute = normalizeRoute(store.vars.ASSET_ROUTE, '/asset');
64
75
  const assetPrefix = assetRoute === '/' ? '' : assetRoute;
65
- const apiBasePath = normalizeRoute(store.env.API_BASE_PATH, '/api');
76
+ const apiBasePath = normalizeRoute(store.vars.API_BASE_PATH, '/api');
66
77
  const apiBasePrefix = apiBasePath === '/' ? '' : apiBasePath;
67
78
  const assetHandler = createAssetHandler(server);
68
79
  const assetMounts = new Set();
@@ -77,23 +88,23 @@ export async function createMailMagicServer(overrides = {}) {
77
88
  // (and remain reachable before the API 404 handler).
78
89
  server.useExpress(`${prefix}/:domain/*path`, assetHandler);
79
90
  }
80
- if (store.env.ADMIN_ENABLED) {
91
+ if (store.vars.ADMIN_ENABLED) {
81
92
  await enableAdminFeatures(server, store, adminUiPath);
82
93
  }
83
94
  else {
84
95
  store.print_debug('Admin UI/API disabled via ADMIN_ENABLED');
85
96
  }
86
- return { server, store, env: store.env };
97
+ return { server, store, vars: store.vars };
87
98
  }
88
- export async function startMailMagicServer(overrides = {}) {
89
- const bootstrap = await createMailMagicServer(overrides);
99
+ export async function startMailMagicServer(overrides = {}, envOverrides = {}) {
100
+ const bootstrap = await createMailMagicServer(overrides, envOverrides);
90
101
  await bootstrap.server.start();
91
102
  return bootstrap;
92
103
  }
93
104
  async function bootMailMagic() {
94
105
  try {
95
- const { env } = await startMailMagicServer();
96
- console.log(`mail-magic server listening on ${env.API_HOST}:${env.API_PORT}`);
106
+ const { vars } = await startMailMagicServer();
107
+ console.log(`mail-magic server listening on ${vars.API_HOST}:${vars.API_PORT}`);
97
108
  }
98
109
  catch (err) {
99
110
  console.error('Failed to start FormMailer:', err);
@@ -118,7 +129,7 @@ async function resolveAdminUiPath(store) {
118
129
  try {
119
130
  const mod = (await import('@technomoron/mail-magic-admin'));
120
131
  if (typeof mod?.resolveAdminDist === 'function') {
121
- return mod.resolveAdminDist(store.env.ADMIN_APP_PATH, (message) => store.print_debug(message));
132
+ return mod.resolveAdminDist(store.vars.ADMIN_APP_PATH, (message) => store.print_debug(message));
122
133
  }
123
134
  }
124
135
  catch (err) {
@@ -131,9 +142,9 @@ async function enableAdminFeatures(server, store, adminUiPath) {
131
142
  const mod = (await import('@technomoron/mail-magic-admin'));
132
143
  if (typeof mod?.registerAdmin === 'function') {
133
144
  await mod.registerAdmin(server, {
134
- apiBasePath: normalizeRoute(store.env.API_BASE_PATH, '/api'),
135
- assetRoute: normalizeRoute(store.env.ASSET_ROUTE, '/asset'),
136
- appPath: adminUiPath ?? store.env.ADMIN_APP_PATH,
145
+ apiBasePath: normalizeRoute(store.vars.API_BASE_PATH, '/api'),
146
+ assetRoute: normalizeRoute(store.vars.ASSET_ROUTE, '/asset'),
147
+ appPath: adminUiPath ?? store.vars.ADMIN_APP_PATH,
137
148
  logger: (message) => store.print_debug(message),
138
149
  staticFallback: Boolean(adminUiPath)
139
150
  });
package/dist/models/db.js CHANGED
@@ -72,12 +72,13 @@ export async function init_api_db(db, store) {
72
72
  as: 'domain'
73
73
  });
74
74
  await db.query('PRAGMA foreign_keys = OFF');
75
- store.print_debug(`Force alter tables: ${store.env.DB_FORCE_SYNC}`);
76
- await db.sync({ alter: true, force: store.env.DB_FORCE_SYNC });
75
+ const alter = Boolean(store.vars.DB_SYNC_ALTER);
76
+ store.print_debug(`DB sync: alter=${alter} force=${store.vars.DB_FORCE_SYNC}`);
77
+ await db.sync({ alter, force: store.vars.DB_FORCE_SYNC });
77
78
  await db.query('PRAGMA foreign_keys = ON');
78
79
  await importData(store);
79
80
  try {
80
- const { migrated, cleared } = await migrateLegacyApiTokens(store.env.API_TOKEN_PEPPER);
81
+ const { migrated, cleared } = await migrateLegacyApiTokens(store.vars.API_TOKEN_PEPPER);
81
82
  if (migrated || cleared) {
82
83
  store.print_debug(`Migrated ${migrated} legacy API token(s) and cleared ${cleared} plaintext token(s).`);
83
84
  }
@@ -89,7 +90,7 @@ export async function init_api_db(db, store) {
89
90
  }
90
91
  export async function connect_api_db(store) {
91
92
  console.log('DB INIT');
92
- const env = store.env;
93
+ const env = store.vars;
93
94
  const dbparams = {
94
95
  logging: false, // env.DB_LOG ? console.log : false,
95
96
  dialect: env.DB_TYPE,
@@ -1,13 +1,22 @@
1
1
  import { Model, DataTypes } from 'sequelize';
2
2
  import { z } from 'zod';
3
- export const api_domain_schema = z.object({
4
- domain_id: z.number().int().nonnegative(),
5
- user_id: z.number().int().nonnegative(),
6
- name: z.string().min(1),
7
- sender: z.string().default(''),
8
- locale: z.string().default(''),
9
- is_default: z.boolean().default(false)
10
- });
3
+ const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
4
+ export const api_domain_schema = z
5
+ .object({
6
+ domain_id: z.number().int().nonnegative().describe('Database primary key for the domain record.'),
7
+ user_id: z.number().int().nonnegative().describe('Owning user ID.'),
8
+ name: z
9
+ .string()
10
+ .min(1)
11
+ .regex(DOMAIN_PATTERN, 'Invalid domain name')
12
+ .describe('Domain name (config identifier).'),
13
+ sender: z.string().default('').describe('Default sender address for this domain.'),
14
+ locale: z.string().default('').describe('Default locale for this domain.'),
15
+ is_default: z.boolean().default(false).describe('If true, this is the default domain for the user.')
16
+ })
17
+ .describe('Domain configuration record.');
18
+ // Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
19
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
11
20
  export class api_domain extends Model {
12
21
  }
13
22
  export async function init_api_domain(api_db) {
@@ -1,31 +1,75 @@
1
1
  import path from 'path';
2
2
  import { nanoid } from 'nanoid';
3
- import { Model, DataTypes } from 'sequelize';
3
+ import { Model, DataTypes, UniqueConstraintError } from 'sequelize';
4
4
  import { z } from 'zod';
5
+ import { assertSafeRelativePath } from '../util/paths.js';
5
6
  import { user_and_domain, normalizeSlug } from '../util.js';
6
- export const api_form_schema = z.object({
7
- form_id: z.number().int().nonnegative(),
8
- form_key: z.string().min(1).nullable().optional(),
9
- user_id: z.number().int().nonnegative(),
10
- domain_id: z.number().int().nonnegative(),
11
- locale: z.string().default(''),
12
- idname: z.string().min(1),
13
- sender: z.string().min(1),
14
- recipient: z.string().min(1),
15
- subject: z.string(),
16
- template: z.string().default(''),
17
- filename: z.string().default(''),
18
- slug: z.string().default(''),
19
- secret: z.string().default(''),
20
- captcha_required: z.boolean().default(false),
7
+ const stored_file_schema = z
8
+ .object({
9
+ filename: z.string().describe('Asset filename (relative to the domain assets directory).'),
10
+ path: z.string().describe('Absolute path on disk where the asset is stored.'),
11
+ cid: z.string().optional().describe('Content-ID used for inline attachments (cid:...) when set.')
12
+ })
13
+ .describe('A stored file/asset referenced by a template.');
14
+ export const api_form_schema = z
15
+ .object({
16
+ form_id: z.number().int().nonnegative().describe('Database primary key for the form configuration record.'),
17
+ form_key: z
18
+ .string()
19
+ .trim()
20
+ .min(1)
21
+ .default(() => nanoid())
22
+ .describe('Public form key required by the unauthenticated form submission endpoint (globally unique).'),
23
+ user_id: z.number().int().nonnegative().describe('Owning user ID.'),
24
+ domain_id: z.number().int().nonnegative().describe('Owning domain ID.'),
25
+ locale: z
26
+ .string()
27
+ .default('')
28
+ .describe('Locale for this form configuration (used for lookup/rendering and template path generation).'),
29
+ idname: z.string().min(1).describe('Form identifier within the domain (slug-like).'),
30
+ sender: z.string().min(1).describe('Email From header used when delivering form submissions.'),
31
+ recipient: z
32
+ .string()
33
+ .min(1)
34
+ .describe('Default email recipient (To) used when delivering form submissions (unless recipients are overridden).'),
35
+ subject: z.string().describe('Email subject used when delivering form submissions.'),
36
+ template: z
37
+ .string()
38
+ .default('')
39
+ .describe('Nunjucks template content used to render the outbound email body for this form.'),
40
+ filename: z
41
+ .string()
42
+ .default('')
43
+ .describe('Relative path (within the config tree) of the source .njk template file for this form.'),
44
+ slug: z.string().default('').describe('Generated slug used to build stable filenames/paths for this form.'),
45
+ secret: z
46
+ .string()
47
+ .default('')
48
+ .describe('Legacy form secret (stored for compatibility; not part of the public form submission contract).'),
49
+ replyto_email: z
50
+ .string()
51
+ .default('')
52
+ .describe('Optional forced Reply-To email address used when reply-to extraction is disabled or fails.'),
53
+ replyto_from_fields: z
54
+ .boolean()
55
+ .default(false)
56
+ .describe('If true, attempt to extract Reply-To from submitted form fields (email + name).'),
57
+ allowed_fields: z
58
+ .array(z.string())
59
+ .default([])
60
+ .describe('Optional allowlist of submitted field names that are exposed to templates as _fields_. When empty, all non-system fields are exposed.'),
61
+ captcha_required: z
62
+ .boolean()
63
+ .default(false)
64
+ .describe('If true, require a captcha token for public submissions to this form (in addition to any server-level requirement).'),
21
65
  files: z
22
- .array(z.object({
23
- filename: z.string(),
24
- path: z.string(),
25
- cid: z.string().optional()
26
- }))
66
+ .array(stored_file_schema)
27
67
  .default([])
28
- });
68
+ .describe('Derived list of template-referenced assets (inline cids and external links) resolved during preprocessing/import.')
69
+ })
70
+ .describe('Form configuration and template used by the public form submission endpoint.');
71
+ // Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
72
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
29
73
  export class api_form extends Model {
30
74
  }
31
75
  export async function init_api_form(api_db) {
@@ -38,8 +82,7 @@ export async function init_api_form(api_db) {
38
82
  },
39
83
  form_key: {
40
84
  type: DataTypes.STRING,
41
- allowNull: true,
42
- defaultValue: null
85
+ allowNull: false
43
86
  },
44
87
  user_id: {
45
88
  type: DataTypes.INTEGER,
@@ -110,6 +153,29 @@ export async function init_api_form(api_db) {
110
153
  allowNull: false,
111
154
  defaultValue: ''
112
155
  },
156
+ replyto_email: {
157
+ type: DataTypes.STRING,
158
+ allowNull: false,
159
+ defaultValue: ''
160
+ },
161
+ replyto_from_fields: {
162
+ type: DataTypes.BOOLEAN,
163
+ allowNull: false,
164
+ defaultValue: false
165
+ },
166
+ allowed_fields: {
167
+ type: DataTypes.TEXT,
168
+ allowNull: false,
169
+ defaultValue: '[]',
170
+ get() {
171
+ // This column is stored as JSON text but exposed as `string[]` via getter/setter.
172
+ const raw = this.getDataValue('allowed_fields');
173
+ return raw ? JSON.parse(raw) : [];
174
+ },
175
+ set(value) {
176
+ this.setDataValue('allowed_fields', JSON.stringify(value ?? []));
177
+ }
178
+ },
113
179
  captcha_required: {
114
180
  type: DataTypes.BOOLEAN,
115
181
  allowNull: false,
@@ -120,6 +186,7 @@ export async function init_api_form(api_db) {
120
186
  allowNull: false,
121
187
  defaultValue: '[]',
122
188
  get() {
189
+ // This column is stored as JSON text but exposed as `StoredFile[]` via getter/setter.
123
190
  const raw = this.getDataValue('files');
124
191
  return raw ? JSON.parse(raw) : [];
125
192
  },
@@ -145,16 +212,6 @@ export async function init_api_form(api_db) {
145
212
  });
146
213
  return api_form;
147
214
  }
148
- function assertSafeRelativePath(filename, label) {
149
- const normalized = path.normalize(filename);
150
- if (path.isAbsolute(normalized)) {
151
- throw new Error(`${label} path must be relative`);
152
- }
153
- if (normalized.split(path.sep).includes('..')) {
154
- throw new Error(`${label} path cannot include '..' segments`);
155
- }
156
- return normalized;
157
- }
158
215
  export async function upsert_form(record) {
159
216
  const { user, domain } = await user_and_domain(record.domain_id);
160
217
  const idname = normalizeSlug(user.idname);
@@ -181,16 +238,31 @@ export async function upsert_form(record) {
181
238
  let instance = null;
182
239
  instance = await api_form.findByPk(record.form_id);
183
240
  if (instance) {
184
- if (!instance.form_key && !record.form_key) {
241
+ // Existing forms should always have a form_key. If not, repair it.
242
+ if (!String(instance.form_key ?? '').trim()) {
185
243
  record.form_key = nanoid();
186
244
  }
187
245
  await instance.update(record);
188
246
  }
189
247
  else {
190
- if (!record.form_key) {
191
- record.form_key = nanoid();
248
+ // form_key must be globally unique; retry on collisions.
249
+ for (let attempt = 0; attempt < 10; attempt++) {
250
+ record.form_key = String(record.form_key ?? '').trim() || nanoid();
251
+ try {
252
+ instance = await api_form.create(record);
253
+ break;
254
+ }
255
+ catch (err) {
256
+ if (err instanceof UniqueConstraintError) {
257
+ const conflicted = err.errors?.some((e) => e.path === 'form_key');
258
+ if (conflicted) {
259
+ record.form_key = nanoid();
260
+ continue;
261
+ }
262
+ }
263
+ throw err;
264
+ }
192
265
  }
193
- instance = await api_form.create(record);
194
266
  }
195
267
  if (!instance) {
196
268
  throw new Error(`Unable to update/create form ${record.form_id}`);
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { Unyuck } from '@technomoron/unyuck';
4
4
  import { z } from 'zod';
5
+ import { buildAssetUrl } from '../util/paths.js';
5
6
  import { user_and_domain } from '../util.js';
6
7
  import { api_domain, api_domain_schema } from './domain.js';
7
8
  import { api_form_schema, upsert_form } from './form.js';
@@ -13,68 +14,6 @@ const init_data_schema = z.object({
13
14
  template: z.array(api_txmail_schema).default([]),
14
15
  form: z.array(api_form_schema).default([])
15
16
  });
16
- /**
17
- * Resolve an asset file within ./config/<domain>/assets
18
- */
19
- function resolveAsset(basePath, domainName, assetName) {
20
- const assetsRoot = path.join(basePath, domainName, 'assets');
21
- if (!fs.existsSync(assetsRoot)) {
22
- return null;
23
- }
24
- const resolvedRoot = fs.realpathSync(assetsRoot);
25
- const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
26
- const candidate = path.resolve(assetsRoot, assetName);
27
- if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
28
- return null;
29
- }
30
- const realCandidate = fs.realpathSync(candidate);
31
- if (!realCandidate.startsWith(normalizedRoot)) {
32
- return null;
33
- }
34
- return realCandidate;
35
- }
36
- function buildAssetUrl(baseUrl, route, domainName, assetPath) {
37
- const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
38
- const normalizedRoute = route ? (route.startsWith('/') ? route : `/${route}`) : '';
39
- const encodedDomain = encodeURIComponent(domainName);
40
- const encodedPath = assetPath
41
- .split('/')
42
- .filter((segment) => segment.length > 0)
43
- .map((segment) => encodeURIComponent(segment))
44
- .join('/');
45
- const trailing = encodedPath ? `/${encodedPath}` : '';
46
- return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
47
- }
48
- function extractAndReplaceAssets(html, opts) {
49
- const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
50
- const assets = [];
51
- const replacedHtml = html.replace(regex, (_m, relPath, inlineFlag) => {
52
- const fullPath = resolveAsset(opts.basePath, opts.domainName, relPath);
53
- if (!fullPath) {
54
- throw new Error(`Missing asset "${relPath}"`);
55
- }
56
- const isInline = inlineFlag === 'true' || inlineFlag === '1';
57
- const storedFile = {
58
- filename: relPath,
59
- path: fullPath,
60
- cid: isInline ? relPath : undefined
61
- };
62
- assets.push(storedFile);
63
- if (isInline) {
64
- return `src="cid:${relPath}"`;
65
- }
66
- const domainAssetsRoot = path.join(opts.basePath, opts.domainName, 'assets');
67
- const relativeToAssets = path.relative(domainAssetsRoot, fullPath);
68
- if (!relativeToAssets || relativeToAssets.startsWith('..')) {
69
- throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
70
- }
71
- const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
72
- const baseUrl = opts.assetBaseUrl?.trim() ? opts.assetBaseUrl : opts.apiUrl;
73
- const assetUrl = buildAssetUrl(baseUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
74
- return `src="${assetUrl}"`;
75
- });
76
- return { html: replacedHtml, assets };
77
- }
78
17
  async function _load_template(store, filename, pathname, user, domain, locale, type) {
79
18
  const rootDir = path.join(store.configpath, domain.name, type);
80
19
  let relFile = filename;
@@ -97,20 +36,41 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
97
36
  }
98
37
  try {
99
38
  const baseConfigPath = store.configpath;
100
- const templateKey = path.relative(baseConfigPath, absPath);
101
- if (!templateKey) {
39
+ const domainRoot = path.join(baseConfigPath, domain.name);
40
+ const templateKey = path.relative(domainRoot, absPath);
41
+ if (!templateKey || templateKey.startsWith('..')) {
102
42
  throw new Error(`Unable to resolve template path for "${absPath}"`);
103
43
  }
104
- const processor = new Unyuck({ basePath: baseConfigPath });
105
- const merged = processor.flattenNoAssets(templateKey);
106
- const { html, assets } = extractAndReplaceAssets(merged, {
107
- basePath: baseConfigPath,
108
- domainName: domain.name,
109
- apiUrl: store.env.API_URL,
110
- assetBaseUrl: store.env.ASSET_PUBLIC_BASE,
111
- assetRoute: store.env.ASSET_ROUTE
44
+ const assetBaseUrl = store.vars.ASSET_PUBLIC_BASE?.trim() ? store.vars.ASSET_PUBLIC_BASE : store.vars.API_URL;
45
+ const assetRoute = store.vars.ASSET_ROUTE;
46
+ const processor = new Unyuck({
47
+ basePath: domainRoot,
48
+ baseUrl: assetBaseUrl,
49
+ collectAssets: true,
50
+ assetFormatter: (ctx) => buildAssetUrl(assetBaseUrl, assetRoute, domain.name, ctx.urlPath)
51
+ });
52
+ const { html: mergedHtml, assets } = processor.flattenWithAssets(templateKey);
53
+ let html = mergedHtml;
54
+ const mappedAssets = assets.map((asset) => {
55
+ const rel = asset.filename.replace(/\\/g, '/');
56
+ const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
57
+ return {
58
+ filename: urlPath,
59
+ path: asset.path,
60
+ cid: asset.cid ? urlPath : undefined
61
+ };
112
62
  });
113
- return { html, assets };
63
+ for (const asset of assets) {
64
+ if (!asset.cid) {
65
+ continue;
66
+ }
67
+ const rel = asset.filename.replace(/\\/g, '/');
68
+ const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
69
+ if (asset.cid !== urlPath) {
70
+ html = html.replaceAll(`cid:${asset.cid}`, `cid:${urlPath}`);
71
+ }
72
+ }
73
+ return { html, assets: mappedAssets };
114
74
  }
115
75
  catch (err) {
116
76
  throw new Error(`Template "${absPath}" failed to preprocess: ${err.message}`);
@@ -149,7 +109,7 @@ export async function importData(store) {
149
109
  resolvedTokenHmac = token_hmac;
150
110
  }
151
111
  else if (typeof token === 'string' && token) {
152
- resolvedTokenHmac = apiTokenToHmac(token, store.env.API_TOKEN_PEPPER);
112
+ resolvedTokenHmac = apiTokenToHmac(token, store.vars.API_TOKEN_PEPPER);
153
113
  }
154
114
  else {
155
115
  throw new Error(`User ${record.user_id} is missing token or token_hmac`);
@@ -1,14 +1,18 @@
1
1
  import { Model, DataTypes } from 'sequelize';
2
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(),
3
+ export const api_recipient_schema = z
4
+ .object({
5
+ recipient_id: z.number().int().nonnegative().describe('Database primary key for the recipient record.'),
6
+ domain_id: z.number().int().nonnegative().describe('Owning domain ID.'),
6
7
  // 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
- });
8
+ form_key: z.string().default('').describe('Form key scope. Empty string means domain-wide recipient.'),
9
+ idname: z.string().min(1).describe('Recipient identifier within the scope.'),
10
+ email: z.string().min(1).describe('Recipient email address.'),
11
+ name: z.string().default('').describe('Optional recipient display name.')
12
+ })
13
+ .describe('Recipient routing record for form submissions.');
14
+ // Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
15
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
12
16
  export class api_recipient extends Model {
13
17
  }
14
18
  export async function init_api_recipient(api_db) {
@@ -1,38 +1,35 @@
1
1
  import path from 'path';
2
2
  import { Model, DataTypes } from 'sequelize';
3
3
  import { z } from 'zod';
4
+ import { assertSafeRelativePath } from '../util/paths.js';
4
5
  import { user_and_domain, normalizeSlug } from '../util.js';
5
- export const api_txmail_schema = z.object({
6
- template_id: z.number().int().nonnegative(),
7
- user_id: z.number().int().nonnegative(),
8
- domain_id: z.number().int().nonnegative(),
9
- name: z.string().min(1),
10
- locale: z.string().default(''),
11
- template: z.string().default(''),
12
- filename: z.string().default(''),
13
- sender: z.string().min(1),
14
- subject: z.string(),
15
- slug: z.string().default(''),
6
+ export const api_txmail_schema = z
7
+ .object({
8
+ template_id: z.number().int().nonnegative().describe('Database primary key for the template record.'),
9
+ user_id: z.number().int().nonnegative().describe('Owning user ID.'),
10
+ domain_id: z.number().int().nonnegative().describe('Owning domain ID.'),
11
+ name: z.string().min(1).describe('Template name within the domain.'),
12
+ locale: z.string().default('').describe('Locale for this template configuration.'),
13
+ template: z.string().default('').describe('Nunjucks template content used for rendering.'),
14
+ filename: z.string().default('').describe('Relative path of the source .njk template file.'),
15
+ sender: z.string().min(1).describe('Email From header used when delivering this template.'),
16
+ subject: z.string().describe('Email subject used when delivering this template.'),
17
+ slug: z.string().default('').describe('Generated slug used to build stable filenames/paths.'),
18
+ part: z.boolean().default(false).describe('If true, template is a partial (not a standalone send).'),
16
19
  files: z
17
20
  .array(z.object({
18
- filename: z.string(),
19
- path: z.string(),
20
- cid: z.string().optional()
21
+ filename: z.string().describe('Asset filename (relative to the domain assets directory).'),
22
+ path: z.string().describe('Absolute path on disk where the asset is stored.'),
23
+ cid: z.string().optional().describe('Content-ID used for inline attachments when set.')
21
24
  }))
22
25
  .default([])
23
- });
26
+ .describe('Derived list of template-referenced assets resolved during preprocessing/import.')
27
+ })
28
+ .describe('Transactional email template configuration.');
29
+ // Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
30
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
24
31
  export class api_txmail extends Model {
25
32
  }
26
- function assertSafeRelativePath(filename, label) {
27
- const normalized = path.normalize(filename);
28
- if (path.isAbsolute(normalized)) {
29
- throw new Error(`${label} path must be relative`);
30
- }
31
- if (normalized.split(path.sep).includes('..')) {
32
- throw new Error(`${label} path cannot include '..' segments`);
33
- }
34
- return normalized;
35
- }
36
33
  export async function upsert_txmail(record) {
37
34
  const { user, domain } = await user_and_domain(record.domain_id);
38
35
  const idname = normalizeSlug(user.idname);
@@ -1,16 +1,20 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
  import { Model, DataTypes, Op } from 'sequelize';
3
3
  import { z } from 'zod';
4
- export const api_user_schema = z.object({
5
- user_id: z.number().int().nonnegative(),
6
- idname: z.string().min(1),
7
- token: z.string().min(1).optional(),
8
- token_hmac: z.string().min(1).optional(),
9
- name: z.string().min(1),
10
- email: z.string().email(),
11
- domain: z.number().int().nonnegative().nullable().optional(),
12
- locale: z.string().default('')
13
- });
4
+ export const api_user_schema = z
5
+ .object({
6
+ user_id: z.number().int().nonnegative().describe('Database primary key for the user record.'),
7
+ idname: z.string().min(1).describe('User identifier (slug-like).'),
8
+ token: z.string().min(1).optional().describe('Legacy API token (may be blank after migration).'),
9
+ token_hmac: z.string().min(1).optional().describe('API token digest (HMAC).'),
10
+ name: z.string().min(1).describe('Display name for the user.'),
11
+ email: z.string().email().describe('User email address.'),
12
+ domain: z.number().int().nonnegative().nullable().optional().describe('Default domain ID for the user.'),
13
+ locale: z.string().default('').describe('Default locale for the user.')
14
+ })
15
+ .describe('User account record and API credentials.');
16
+ // Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
17
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
14
18
  export class api_user extends Model {
15
19
  }
16
20
  export function apiTokenToHmac(token, pepper) {