@technomoron/mail-magic 1.0.32 → 1.0.34

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 (43) hide show
  1. package/CHANGES +18 -0
  2. package/README.md +213 -122
  3. package/dist/api/assets.js +9 -56
  4. package/dist/api/auth.js +1 -12
  5. package/dist/api/form-replyto.js +1 -0
  6. package/dist/api/form-submission.js +1 -0
  7. package/dist/api/forms.js +114 -474
  8. package/dist/api/mailer.js +1 -1
  9. package/dist/bin/mail-magic.js +2 -2
  10. package/dist/index.js +30 -18
  11. package/dist/models/db.js +5 -5
  12. package/dist/models/domain.js +16 -8
  13. package/dist/models/form.js +111 -40
  14. package/dist/models/init.js +44 -74
  15. package/dist/models/recipient.js +12 -8
  16. package/dist/models/txmail.js +24 -28
  17. package/dist/models/user.js +14 -10
  18. package/dist/server.js +1 -1
  19. package/dist/store/store.js +53 -22
  20. package/dist/swagger.js +107 -0
  21. package/dist/util/captcha.js +24 -0
  22. package/dist/util/email.js +19 -0
  23. package/dist/util/form-replyto.js +44 -0
  24. package/dist/util/form-submission.js +95 -0
  25. package/dist/util/forms.js +431 -0
  26. package/dist/util/paths.js +41 -0
  27. package/dist/util/ratelimit.js +48 -0
  28. package/dist/util/uploads.js +48 -0
  29. package/dist/util/utils.js +151 -0
  30. package/dist/util.js +7 -127
  31. package/docs/config-example/example.test/assets/files/banner.png +1 -0
  32. package/docs/config-example/example.test/assets/images/logo.png +1 -0
  33. package/docs/config-example/example.test/form-template/base.njk +6 -0
  34. package/docs/config-example/example.test/form-template/contact.njk +9 -0
  35. package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
  36. package/docs/config-example/example.test/tx-template/base.njk +10 -0
  37. package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
  38. package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
  39. package/docs/config-example/init-data.json +57 -0
  40. package/docs/form-security.md +194 -0
  41. package/docs/swagger/openapi.json +1321 -0
  42. package/{TUTORIAL.MD → docs/tutorial.md} +24 -15
  43. package/package.json +3 -3
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
+ import { installMailMagicSwagger } from './swagger.js';
7
8
  function normalizeRoute(value, fallback = '') {
8
9
  if (!value) {
9
10
  return fallback;
@@ -26,7 +27,7 @@ function mergeStaticDirs(base, override) {
26
27
  return merged;
27
28
  }
28
29
  function buildServerConfig(store, overrides) {
29
- const env = store.env;
30
+ const env = store.vars;
30
31
  return {
31
32
  apiHost: env.API_HOST,
32
33
  apiPort: env.API_PORT,
@@ -39,14 +40,14 @@ function buildServerConfig(store, overrides) {
39
40
  ...overrides
40
41
  };
41
42
  }
42
- export async function createMailMagicServer(overrides = {}) {
43
- const store = await new mailStore().init();
43
+ export async function createMailMagicServer(overrides = {}, envOverrides = {}) {
44
+ const store = await new mailStore().init(envOverrides);
44
45
  if (typeof overrides.apiBasePath === 'string') {
45
- store.env.API_BASE_PATH = overrides.apiBasePath;
46
+ store.vars.API_BASE_PATH = overrides.apiBasePath;
46
47
  }
47
48
  const baseStaticDirs = {};
48
49
  let adminUiPath = null;
49
- if (store.env.ADMIN_ENABLED) {
50
+ if (store.vars.ADMIN_ENABLED) {
50
51
  adminUiPath = await resolveAdminUiPath(store);
51
52
  if (adminUiPath) {
52
53
  baseStaticDirs['/'] = adminUiPath;
@@ -57,11 +58,22 @@ export async function createMailMagicServer(overrides = {}) {
57
58
  staticDirs: mergeStaticDirs(baseStaticDirs, overrides.staticDirs)
58
59
  };
59
60
  const config = buildServerConfig(store, mergedOverrides);
60
- 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
+ });
61
73
  // Serve domain assets from a public route with traversal protection and caching.
62
- const assetRoute = normalizeRoute(store.env.ASSET_ROUTE, '/asset');
74
+ const assetRoute = normalizeRoute(store.vars.ASSET_ROUTE, '/asset');
63
75
  const assetPrefix = assetRoute === '/' ? '' : assetRoute;
64
- const apiBasePath = normalizeRoute(store.env.API_BASE_PATH, '/api');
76
+ const apiBasePath = normalizeRoute(store.vars.API_BASE_PATH, '/api');
65
77
  const apiBasePrefix = apiBasePath === '/' ? '' : apiBasePath;
66
78
  const assetHandler = createAssetHandler(server);
67
79
  const assetMounts = new Set();
@@ -76,23 +88,23 @@ export async function createMailMagicServer(overrides = {}) {
76
88
  // (and remain reachable before the API 404 handler).
77
89
  server.useExpress(`${prefix}/:domain/*path`, assetHandler);
78
90
  }
79
- if (store.env.ADMIN_ENABLED) {
91
+ if (store.vars.ADMIN_ENABLED) {
80
92
  await enableAdminFeatures(server, store, adminUiPath);
81
93
  }
82
94
  else {
83
95
  store.print_debug('Admin UI/API disabled via ADMIN_ENABLED');
84
96
  }
85
- return { server, store, env: store.env };
97
+ return { server, store, vars: store.vars };
86
98
  }
87
- export async function startMailMagicServer(overrides = {}) {
88
- const bootstrap = await createMailMagicServer(overrides);
99
+ export async function startMailMagicServer(overrides = {}, envOverrides = {}) {
100
+ const bootstrap = await createMailMagicServer(overrides, envOverrides);
89
101
  await bootstrap.server.start();
90
102
  return bootstrap;
91
103
  }
92
104
  async function bootMailMagic() {
93
105
  try {
94
- const { env } = await startMailMagicServer();
95
- 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}`);
96
108
  }
97
109
  catch (err) {
98
110
  console.error('Failed to start FormMailer:', err);
@@ -117,7 +129,7 @@ async function resolveAdminUiPath(store) {
117
129
  try {
118
130
  const mod = (await import('@technomoron/mail-magic-admin'));
119
131
  if (typeof mod?.resolveAdminDist === 'function') {
120
- 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));
121
133
  }
122
134
  }
123
135
  catch (err) {
@@ -130,9 +142,9 @@ async function enableAdminFeatures(server, store, adminUiPath) {
130
142
  const mod = (await import('@technomoron/mail-magic-admin'));
131
143
  if (typeof mod?.registerAdmin === 'function') {
132
144
  await mod.registerAdmin(server, {
133
- apiBasePath: normalizeRoute(store.env.API_BASE_PATH, '/api'),
134
- assetRoute: normalizeRoute(store.env.ASSET_ROUTE, '/asset'),
135
- 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,
136
148
  logger: (message) => store.print_debug(message),
137
149
  staticFallback: Boolean(adminUiPath)
138
150
  });
package/dist/models/db.js CHANGED
@@ -72,13 +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
- const alter = Boolean(store.env.DB_SYNC_ALTER);
76
- store.print_debug(`DB sync: alter=${alter} force=${store.env.DB_FORCE_SYNC}`);
77
- await db.sync({ alter, 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 });
78
78
  await db.query('PRAGMA foreign_keys = ON');
79
79
  await importData(store);
80
80
  try {
81
- const { migrated, cleared } = await migrateLegacyApiTokens(store.env.API_TOKEN_PEPPER);
81
+ const { migrated, cleared } = await migrateLegacyApiTokens(store.vars.API_TOKEN_PEPPER);
82
82
  if (migrated || cleared) {
83
83
  store.print_debug(`Migrated ${migrated} legacy API token(s) and cleared ${cleared} plaintext token(s).`);
84
84
  }
@@ -90,7 +90,7 @@ export async function init_api_db(db, store) {
90
90
  }
91
91
  export async function connect_api_db(store) {
92
92
  console.log('DB INIT');
93
- const env = store.env;
93
+ const env = store.vars;
94
94
  const dbparams = {
95
95
  logging: false, // env.DB_LOG ? console.log : false,
96
96
  dialect: env.DB_TYPE,
@@ -1,14 +1,22 @@
1
1
  import { Model, DataTypes } from 'sequelize';
2
2
  import { z } from 'zod';
3
3
  const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
4
- export const api_domain_schema = z.object({
5
- domain_id: z.number().int().nonnegative(),
6
- user_id: z.number().int().nonnegative(),
7
- name: z.string().min(1).regex(DOMAIN_PATTERN, 'Invalid domain name'),
8
- sender: z.string().default(''),
9
- locale: z.string().default(''),
10
- is_default: z.boolean().default(false)
11
- });
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
12
20
  export class api_domain extends Model {
13
21
  }
14
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 for this form record (domain + locale + idname).'),
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,24 +212,13 @@ 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
- const idname = normalizeSlug(user.idname);
161
217
  const dname = normalizeSlug(domain.name);
162
218
  const name = normalizeSlug(record.idname);
163
219
  const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
164
220
  if (!record.slug) {
165
- record.slug = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
221
+ record.slug = `${dname}${locale ? '-' + locale : ''}-${name}`;
166
222
  }
167
223
  if (!record.filename) {
168
224
  const parts = [dname, 'form-template'];
@@ -181,16 +237,31 @@ export async function upsert_form(record) {
181
237
  let instance = null;
182
238
  instance = await api_form.findByPk(record.form_id);
183
239
  if (instance) {
184
- if (!instance.form_key && !record.form_key) {
240
+ // Existing forms should always have a form_key. If not, repair it.
241
+ if (!String(instance.form_key ?? '').trim()) {
185
242
  record.form_key = nanoid();
186
243
  }
187
244
  await instance.update(record);
188
245
  }
189
246
  else {
190
- if (!record.form_key) {
191
- record.form_key = nanoid();
247
+ // form_key must be globally unique; retry on collisions.
248
+ for (let attempt = 0; attempt < 10; attempt++) {
249
+ record.form_key = String(record.form_key ?? '').trim() || nanoid();
250
+ try {
251
+ instance = await api_form.create(record);
252
+ break;
253
+ }
254
+ catch (err) {
255
+ if (err instanceof UniqueConstraintError) {
256
+ const conflicted = err.errors?.some((e) => e.path === 'form_key');
257
+ if (conflicted) {
258
+ record.form_key = nanoid();
259
+ continue;
260
+ }
261
+ }
262
+ throw err;
263
+ }
192
264
  }
193
- instance = await api_form.create(record);
194
265
  }
195
266
  if (!instance) {
196
267
  throw new Error(`Unable to update/create form ${record.form_id}`);
@@ -2,79 +2,27 @@ 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';
8
9
  import { api_txmail_schema, upsert_txmail } from './txmail.js';
9
10
  import { apiTokenToHmac, api_user, api_user_schema } from './user.js';
11
+ function buildInlineAssetCid(urlPath) {
12
+ // Many mail clients are picky about Content-ID values. Keep it stable and avoid path separators.
13
+ // Use a sanitized urlPath so nested assets remain unique without embedding `/` in the CID.
14
+ const normalized = String(urlPath || '')
15
+ .trim()
16
+ .replace(/\\/g, '/');
17
+ const safe = normalized.replace(/[^A-Za-z0-9._-]/g, '_').replace(/_+/g, '_');
18
+ return (safe || 'asset').slice(0, 200);
19
+ }
10
20
  const init_data_schema = z.object({
11
21
  user: z.array(api_user_schema).default([]),
12
22
  domain: z.array(api_domain_schema).default([]),
13
23
  template: z.array(api_txmail_schema).default([]),
14
24
  form: z.array(api_form_schema).default([])
15
25
  });
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
26
  async function _load_template(store, filename, pathname, user, domain, locale, type) {
79
27
  const rootDir = path.join(store.configpath, domain.name, type);
80
28
  let relFile = filename;
@@ -97,20 +45,42 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
97
45
  }
98
46
  try {
99
47
  const baseConfigPath = store.configpath;
100
- const templateKey = path.relative(baseConfigPath, absPath);
101
- if (!templateKey) {
48
+ const domainRoot = path.join(baseConfigPath, domain.name);
49
+ const templateKey = path.relative(domainRoot, absPath);
50
+ if (!templateKey || templateKey.startsWith('..')) {
102
51
  throw new Error(`Unable to resolve template path for "${absPath}"`);
103
52
  }
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
53
+ const assetBaseUrl = store.vars.ASSET_PUBLIC_BASE?.trim() ? store.vars.ASSET_PUBLIC_BASE : store.vars.API_URL;
54
+ const assetRoute = store.vars.ASSET_ROUTE;
55
+ const processor = new Unyuck({
56
+ basePath: domainRoot,
57
+ baseUrl: assetBaseUrl,
58
+ collectAssets: true,
59
+ assetFormatter: (ctx) => buildAssetUrl(assetBaseUrl, assetRoute, domain.name, ctx.urlPath)
60
+ });
61
+ const { html: mergedHtml, assets } = processor.flattenWithAssets(templateKey);
62
+ let html = mergedHtml;
63
+ const mappedAssets = assets.map((asset) => {
64
+ const rel = asset.filename.replace(/\\/g, '/');
65
+ const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
66
+ return {
67
+ filename: urlPath,
68
+ path: asset.path,
69
+ cid: asset.cid ? buildInlineAssetCid(urlPath) : undefined
70
+ };
112
71
  });
113
- return { html, assets };
72
+ for (const asset of assets) {
73
+ if (!asset.cid) {
74
+ continue;
75
+ }
76
+ const rel = asset.filename.replace(/\\/g, '/');
77
+ const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
78
+ const desiredCid = buildInlineAssetCid(urlPath);
79
+ if (asset.cid !== desiredCid) {
80
+ html = html.replaceAll(`cid:${asset.cid}`, `cid:${desiredCid}`);
81
+ }
82
+ }
83
+ return { html, assets: mappedAssets };
114
84
  }
115
85
  catch (err) {
116
86
  throw new Error(`Template "${absPath}" failed to preprocess: ${err.message}`);
@@ -149,7 +119,7 @@ export async function importData(store) {
149
119
  resolvedTokenHmac = token_hmac;
150
120
  }
151
121
  else if (typeof token === 'string' && token) {
152
- resolvedTokenHmac = apiTokenToHmac(token, store.env.API_TOKEN_PEPPER);
122
+ resolvedTokenHmac = apiTokenToHmac(token, store.vars.API_TOKEN_PEPPER);
153
123
  }
154
124
  else {
155
125
  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,46 +1,42 @@
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 for this template record (domain + locale + name).'),
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
- const idname = normalizeSlug(user.idname);
39
35
  const dname = normalizeSlug(domain.name);
40
36
  const name = normalizeSlug(record.name);
41
37
  const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
42
38
  if (!record.slug) {
43
- record.slug = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
39
+ record.slug = `${dname}${locale ? '-' + locale : ''}-${name}`;
44
40
  }
45
41
  if (!record.filename) {
46
42
  const parts = [dname, 'tx-template'];
@@ -158,7 +154,7 @@ export async function init_api_txmail(api_db) {
158
154
  const dname = normalizeSlug(domain.name);
159
155
  const name = normalizeSlug(template.name);
160
156
  const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
161
- template.slug ||= `${normalizeSlug(user.idname)}-${dname}${locale ? '-' + locale : ''}-${name}`;
157
+ template.slug ||= `${dname}${locale ? '-' + locale : ''}-${name}`;
162
158
  if (!template.filename) {
163
159
  const parts = [dname, 'tx-template'];
164
160
  if (locale)