@technomoron/mail-magic-client 1.0.25 → 1.0.28

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/CHANGES CHANGED
@@ -1,3 +1,31 @@
1
+ Version 1.0.29 (2026-02-11)
2
+
3
+ - Expand client coverage for mail-magic-owned endpoints:
4
+ - Add `storeFormRecipient()` for `POST /api/v1/form/recipient`.
5
+ - Add `getSwaggerSpec()` for `GET /api/swagger`.
6
+ - Add `fetchPublicAsset()` for public asset routes (`/asset/...` and `/api/asset/...`).
7
+ - Extend `storeFormTemplate()` input support for newer form fields:
8
+ `replyto_email`, `replyto_from_fields`, `allowed_fields`, and `captcha_required`.
9
+ - Add tests for the new client methods and route mappings.
10
+ - Refresh README usage docs for recipient upserts, swagger retrieval, and public asset fetch helpers.
11
+
12
+ Version 1.0.28 (2026-02-08)
13
+
14
+ - Refresh README/examples to match current server auth and public form submission semantics (`_mm_form_key`,
15
+ `_mm_locale`, `_mm_recipients`).
16
+ - Update `sendFormMessage()` to require `_mm_form_key` and default attachment multipart fields to `_mm_fileN`.
17
+
18
+ Version 1.0.27 (2026-02-07)
19
+
20
+ - Harden the template compiler to prevent `{% include %}` paths from escaping the
21
+ configured template root directory.
22
+
23
+ Version 1.0.26 (2026-02-07)
24
+
25
+ - Fix `GET` requests to omit request bodies and only set `Content-Type` when
26
+ sending JSON payloads.
27
+ - `mm-cli version` now reports the package version instead of a hardcoded string.
28
+
1
29
  Version 1.0.25 (2026-02-07)
2
30
 
3
31
  - `sendFormMessage()` now requires `domain` when sending by `formid`, and also
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # mail-magic-client
1
+ # @technomoron/mail-magic-client
2
2
 
3
3
  Client library and CLI for the mail-magic server.
4
4
 
@@ -13,7 +13,13 @@ npm install @technomoron/mail-magic-client
13
13
  ```ts
14
14
  import TemplateClient from '@technomoron/mail-magic-client';
15
15
 
16
- const client = new TemplateClient('http://localhost:3000', 'username:token');
16
+ // Use the server origin (no /api).
17
+ const baseUrl = 'http://127.0.0.1:3776';
18
+
19
+ // This is the user token from init-data.json / the admin API.
20
+ const token = 'example-token';
21
+
22
+ const client = new TemplateClient(baseUrl, token);
17
23
 
18
24
  await client.storeTxTemplate({
19
25
  domain: 'example.test',
@@ -31,6 +37,62 @@ await client.sendTxMessage({
31
37
  });
32
38
  ```
33
39
 
40
+ ## Forms
41
+
42
+ Store/update a form template (authenticated). The response includes `data.form_key`, a stable random identifier (nanoid)
43
+ that is preferred for public form submissions:
44
+
45
+ ```ts
46
+ const res = await client.storeFormTemplate({
47
+ domain: 'example.test',
48
+ idname: 'contact',
49
+ sender: 'Example Forms <forms@example.test>',
50
+ recipient: 'owner@example.test',
51
+ subject: 'New contact form submission',
52
+ secret: 's3cret',
53
+ template: '<p>Hello {{ _fields_.name }}</p>'
54
+ });
55
+
56
+ const form_key = res.data.form_key;
57
+ ```
58
+
59
+ Store/update form recipient mappings (authenticated):
60
+
61
+ ```ts
62
+ await client.storeFormRecipient({
63
+ domain: 'example.test',
64
+ idname: 'support',
65
+ email: 'Support <support@example.test>',
66
+ name: 'Support Team',
67
+ formid: 'contact',
68
+ locale: 'en'
69
+ });
70
+ ```
71
+
72
+ Submit a form publicly (no auth required):
73
+
74
+ ```ts
75
+ await fetch(`${baseUrl}/api/v1/form/message`, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify({
79
+ _mm_form_key: form_key,
80
+ name: 'Sam',
81
+ email: 'sam@example.test',
82
+ message: 'Hello from the website'
83
+ })
84
+ });
85
+ ```
86
+
87
+ If you want to use the client helper (`sendFormMessage()`), pass `_mm_form_key` (public form key):
88
+
89
+ ```ts
90
+ await client.sendFormMessage({
91
+ _mm_form_key: form_key,
92
+ fields: { name: 'Sam', email: 'sam@example.test', message: 'Hello' }
93
+ });
94
+ ```
95
+
34
96
  ## CLI
35
97
 
36
98
  The package ships `mm-cli`.
@@ -40,14 +102,14 @@ The package ships `mm-cli`.
40
102
  Create `.mmcli-env` in your working directory to set defaults:
41
103
 
42
104
  ```ini
43
- MMCLI_API=http://localhost:3000
44
- MMCLI_TOKEN=username:token
45
- # or, split token:
46
- MMCLI_USERNAME=username
47
- MMCLI_PASSWORD=token
105
+ MMCLI_API=http://127.0.0.1:3776
106
+ MMCLI_TOKEN=example-token
48
107
  MMCLI_DOMAIN=example.test
49
108
  ```
50
109
 
110
+ `MMCLI_TOKEN` is treated as the server token string. As a convenience, `MMCLI_USERNAME` + `MMCLI_PASSWORD` can be used
111
+ to build a combined token string (for legacy setups).
112
+
51
113
  ### Template Commands
52
114
 
53
115
  Compile a template locally:
@@ -100,3 +162,7 @@ mm-cli assets --file ./hero.png --domain example.test --template-type tx --templ
100
162
 
101
163
  - `push-dir` expects a `init-data.json` and domain folders that match the server config layout.
102
164
  - Asset uploads use the server endpoint `POST /api/v1/assets`.
165
+ - OpenAPI spec (when enabled): `await client.getSwaggerSpec()`
166
+ - Public asset fetch helpers:
167
+ - `await client.fetchPublicAsset('example.test', 'images/logo.png')` -> `/asset/{domain}/{path}`
168
+ - `await client.fetchPublicAsset('example.test', 'images/logo.png', true)` -> `/api/asset/{domain}/{path}`
@@ -21,6 +21,19 @@ interface formTemplateData {
21
21
  subject?: string;
22
22
  locale?: string;
23
23
  secret?: string;
24
+ replyto_email?: string;
25
+ replyto_from_fields?: boolean;
26
+ allowed_fields?: string[] | string;
27
+ captcha_required?: boolean;
28
+ }
29
+ interface formRecipientData {
30
+ domain: string;
31
+ idname: string;
32
+ email: string;
33
+ name?: string;
34
+ form_key?: string;
35
+ formid?: string;
36
+ locale?: string;
24
37
  }
25
38
  interface sendTemplateData {
26
39
  name: string;
@@ -33,14 +46,9 @@ interface sendTemplateData {
33
46
  attachments?: AttachmentInput[];
34
47
  }
35
48
  interface sendFormData {
36
- formid?: string;
37
- form_key?: string;
38
- secret?: string;
39
- recipient?: string;
40
- domain?: string;
41
- locale?: string;
42
- vars?: Record<string, unknown>;
43
- replyTo?: string;
49
+ _mm_form_key: string;
50
+ _mm_locale?: string;
51
+ _mm_recipients?: string[] | string;
44
52
  fields?: Record<string, unknown>;
45
53
  attachments?: AttachmentInput[];
46
54
  }
@@ -82,7 +90,10 @@ declare class templateClient {
82
90
  storeTxTemplate(td: templateData): Promise<unknown>;
83
91
  sendTxMessage(std: sendTemplateData): Promise<unknown>;
84
92
  storeFormTemplate(data: formTemplateData): Promise<unknown>;
93
+ storeFormRecipient(data: formRecipientData): Promise<unknown>;
85
94
  sendFormMessage(data: sendFormData): Promise<unknown>;
86
95
  uploadAssets(data: uploadAssetsData): Promise<unknown>;
96
+ getSwaggerSpec(): Promise<unknown>;
97
+ fetchPublicAsset(domain: string, assetPath: string, viaApiBase?: boolean): Promise<ArrayBuffer>;
87
98
  }
88
99
  export default templateClient;
@@ -17,14 +17,19 @@ class templateClient {
17
17
  }
18
18
  async request(method, command, body) {
19
19
  const url = `${this.baseURL}${command}`;
20
+ const headers = {
21
+ Accept: 'application/json',
22
+ Authorization: `Bearer apikey-${this.apiKey}`
23
+ };
20
24
  const options = {
21
25
  method,
22
- headers: {
23
- 'Content-Type': 'application/json',
24
- Authorization: `Bearer apikey-${this.apiKey}`
25
- },
26
- body: body ? JSON.stringify(body) : '{}'
26
+ headers
27
27
  };
28
+ // Avoid GET bodies (they're non-standard and can break under some proxies).
29
+ if (method !== 'GET' && body !== undefined) {
30
+ headers['Content-Type'] = 'application/json';
31
+ options.body = JSON.stringify(body);
32
+ }
28
33
  // console.log(JSON.stringify({ options, url }));
29
34
  const response = await fetch(url, options);
30
35
  const j = await response.json();
@@ -217,36 +222,46 @@ class templateClient {
217
222
  this.validateSender(data.sender);
218
223
  return this.post('/api/v1/form/template', data);
219
224
  }
220
- async sendFormMessage(data) {
221
- if (!data.form_key && !data.formid) {
222
- throw new Error('Invalid request body; formid or form_key required');
225
+ async storeFormRecipient(data) {
226
+ if (!data.domain) {
227
+ throw new Error('Missing domain');
223
228
  }
224
- if (!data.form_key && !data.domain) {
225
- throw new Error('Invalid request body; domain required when sending by formid');
229
+ if (!data.idname) {
230
+ throw new Error('Missing recipient identifier');
231
+ }
232
+ if (!data.email) {
233
+ throw new Error('Missing recipient email');
234
+ }
235
+ const parsed = email_addresses_1.default.parseOneAddress(data.email);
236
+ if (!parsed || !parsed.address) {
237
+ throw new Error('Invalid recipient email address');
238
+ }
239
+ return this.post('/api/v1/form/recipient', data);
240
+ }
241
+ async sendFormMessage(data) {
242
+ if (!data._mm_form_key) {
243
+ throw new Error('Invalid request body; _mm_form_key required');
226
244
  }
227
245
  const fields = data.fields || {};
228
246
  const baseFields = {
229
- formid: data.formid,
230
- form_key: data.form_key,
231
- secret: data.secret,
232
- recipient: data.recipient,
233
- domain: data.domain,
234
- locale: data.locale,
235
- vars: data.vars || {},
236
- replyTo: data.replyTo,
247
+ _mm_form_key: data._mm_form_key,
248
+ _mm_locale: data._mm_locale,
249
+ _mm_recipients: data._mm_recipients,
237
250
  ...fields
238
251
  };
239
252
  if (data.attachments && data.attachments.length > 0) {
240
- const { formData } = this.createAttachmentPayload(data.attachments);
253
+ const normalized = data.attachments.map((attachment, idx) => {
254
+ const field = attachment.field || `_mm_file${idx + 1}`;
255
+ if (!field.startsWith('_mm_file')) {
256
+ throw new Error('Form attachments must use multipart field names starting with _mm_file');
257
+ }
258
+ return { ...attachment, field };
259
+ });
260
+ const { formData } = this.createAttachmentPayload(normalized);
241
261
  this.appendFields(formData, {
242
- formid: data.formid,
243
- form_key: data.form_key,
244
- secret: data.secret,
245
- recipient: data.recipient,
246
- domain: data.domain,
247
- locale: data.locale,
248
- vars: JSON.stringify(data.vars || {}),
249
- replyTo: data.replyTo
262
+ _mm_form_key: data._mm_form_key,
263
+ _mm_locale: data._mm_locale,
264
+ _mm_recipients: data._mm_recipients
250
265
  });
251
266
  this.appendFields(formData, fields);
252
267
  return this.postFormData('/api/v1/form/message', formData);
@@ -282,5 +297,36 @@ class templateClient {
282
297
  });
283
298
  return this.postFormData('/api/v1/assets', formData);
284
299
  }
300
+ async getSwaggerSpec() {
301
+ return this.get('/api/swagger');
302
+ }
303
+ async fetchPublicAsset(domain, assetPath, viaApiBase = false) {
304
+ if (!domain) {
305
+ throw new Error('domain is required');
306
+ }
307
+ if (!assetPath) {
308
+ throw new Error('assetPath is required');
309
+ }
310
+ const cleanedPath = assetPath
311
+ .split('/')
312
+ .filter(Boolean)
313
+ .map((segment) => encodeURIComponent(segment))
314
+ .join('/');
315
+ if (!cleanedPath) {
316
+ throw new Error('assetPath is required');
317
+ }
318
+ const prefix = viaApiBase ? '/api/asset' : '/asset';
319
+ const url = `${this.baseURL}${prefix}/${encodeURIComponent(domain)}/${cleanedPath}`;
320
+ const response = await fetch(url, {
321
+ method: 'GET',
322
+ headers: {
323
+ Accept: '*/*'
324
+ }
325
+ });
326
+ if (!response.ok) {
327
+ throw new Error(`FETCH FAILED: ${response.status} ${response.statusText}`);
328
+ }
329
+ return response.arrayBuffer();
330
+ }
285
331
  }
286
332
  exports.default = templateClient;
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
8
9
  const readline_1 = __importDefault(require("readline"));
9
10
  const commander_1 = require("commander");
10
11
  const cli_env_1 = require("./cli-env");
@@ -15,6 +16,16 @@ const program = new commander_1.Command();
15
16
  const envDefaults = (0, cli_env_1.loadCliEnv)();
16
17
  const defaultToken = (0, cli_env_1.resolveToken)(envDefaults);
17
18
  const apiDefault = envDefaults.api || 'http://localhost:3000';
19
+ function resolvePackageVersion() {
20
+ try {
21
+ const raw = fs_1.default.readFileSync(path_1.default.join(__dirname, '../package.json'), 'utf8');
22
+ const data = JSON.parse(raw);
23
+ return typeof data.version === 'string' && data.version ? data.version : 'unknown';
24
+ }
25
+ catch {
26
+ return 'unknown';
27
+ }
28
+ }
18
29
  program.option('-a, --api <api>', 'Base API endpoint', apiDefault);
19
30
  if (defaultToken) {
20
31
  program.option('-t, --token <token>', 'Authentication token in the format "username:token"', defaultToken);
@@ -144,7 +155,7 @@ program
144
155
  .command('version')
145
156
  .description('Show current client version')
146
157
  .action(async () => {
147
- console.log('1.0.19');
158
+ console.log(resolvePackageVersion());
148
159
  });
149
160
  program
150
161
  .command('compile')
@@ -12,14 +12,19 @@ class templateClient {
12
12
  }
13
13
  async request(method, command, body) {
14
14
  const url = `${this.baseURL}${command}`;
15
+ const headers = {
16
+ Accept: 'application/json',
17
+ Authorization: `Bearer apikey-${this.apiKey}`
18
+ };
15
19
  const options = {
16
20
  method,
17
- headers: {
18
- 'Content-Type': 'application/json',
19
- Authorization: `Bearer apikey-${this.apiKey}`
20
- },
21
- body: body ? JSON.stringify(body) : '{}'
21
+ headers
22
22
  };
23
+ // Avoid GET bodies (they're non-standard and can break under some proxies).
24
+ if (method !== 'GET' && body !== undefined) {
25
+ headers['Content-Type'] = 'application/json';
26
+ options.body = JSON.stringify(body);
27
+ }
23
28
  // console.log(JSON.stringify({ options, url }));
24
29
  const response = await fetch(url, options);
25
30
  const j = await response.json();
@@ -212,36 +217,46 @@ class templateClient {
212
217
  this.validateSender(data.sender);
213
218
  return this.post('/api/v1/form/template', data);
214
219
  }
215
- async sendFormMessage(data) {
216
- if (!data.form_key && !data.formid) {
217
- throw new Error('Invalid request body; formid or form_key required');
220
+ async storeFormRecipient(data) {
221
+ if (!data.domain) {
222
+ throw new Error('Missing domain');
218
223
  }
219
- if (!data.form_key && !data.domain) {
220
- throw new Error('Invalid request body; domain required when sending by formid');
224
+ if (!data.idname) {
225
+ throw new Error('Missing recipient identifier');
226
+ }
227
+ if (!data.email) {
228
+ throw new Error('Missing recipient email');
229
+ }
230
+ const parsed = emailAddresses.parseOneAddress(data.email);
231
+ if (!parsed || !parsed.address) {
232
+ throw new Error('Invalid recipient email address');
233
+ }
234
+ return this.post('/api/v1/form/recipient', data);
235
+ }
236
+ async sendFormMessage(data) {
237
+ if (!data._mm_form_key) {
238
+ throw new Error('Invalid request body; _mm_form_key required');
221
239
  }
222
240
  const fields = data.fields || {};
223
241
  const baseFields = {
224
- formid: data.formid,
225
- form_key: data.form_key,
226
- secret: data.secret,
227
- recipient: data.recipient,
228
- domain: data.domain,
229
- locale: data.locale,
230
- vars: data.vars || {},
231
- replyTo: data.replyTo,
242
+ _mm_form_key: data._mm_form_key,
243
+ _mm_locale: data._mm_locale,
244
+ _mm_recipients: data._mm_recipients,
232
245
  ...fields
233
246
  };
234
247
  if (data.attachments && data.attachments.length > 0) {
235
- const { formData } = this.createAttachmentPayload(data.attachments);
248
+ const normalized = data.attachments.map((attachment, idx) => {
249
+ const field = attachment.field || `_mm_file${idx + 1}`;
250
+ if (!field.startsWith('_mm_file')) {
251
+ throw new Error('Form attachments must use multipart field names starting with _mm_file');
252
+ }
253
+ return { ...attachment, field };
254
+ });
255
+ const { formData } = this.createAttachmentPayload(normalized);
236
256
  this.appendFields(formData, {
237
- formid: data.formid,
238
- form_key: data.form_key,
239
- secret: data.secret,
240
- recipient: data.recipient,
241
- domain: data.domain,
242
- locale: data.locale,
243
- vars: JSON.stringify(data.vars || {}),
244
- replyTo: data.replyTo
257
+ _mm_form_key: data._mm_form_key,
258
+ _mm_locale: data._mm_locale,
259
+ _mm_recipients: data._mm_recipients
245
260
  });
246
261
  this.appendFields(formData, fields);
247
262
  return this.postFormData('/api/v1/form/message', formData);
@@ -277,5 +292,36 @@ class templateClient {
277
292
  });
278
293
  return this.postFormData('/api/v1/assets', formData);
279
294
  }
295
+ async getSwaggerSpec() {
296
+ return this.get('/api/swagger');
297
+ }
298
+ async fetchPublicAsset(domain, assetPath, viaApiBase = false) {
299
+ if (!domain) {
300
+ throw new Error('domain is required');
301
+ }
302
+ if (!assetPath) {
303
+ throw new Error('assetPath is required');
304
+ }
305
+ const cleanedPath = assetPath
306
+ .split('/')
307
+ .filter(Boolean)
308
+ .map((segment) => encodeURIComponent(segment))
309
+ .join('/');
310
+ if (!cleanedPath) {
311
+ throw new Error('assetPath is required');
312
+ }
313
+ const prefix = viaApiBase ? '/api/asset' : '/asset';
314
+ const url = `${this.baseURL}${prefix}/${encodeURIComponent(domain)}/${cleanedPath}`;
315
+ const response = await fetch(url, {
316
+ method: 'GET',
317
+ headers: {
318
+ Accept: '*/*'
319
+ }
320
+ });
321
+ if (!response.ok) {
322
+ throw new Error(`FETCH FAILED: ${response.status} ${response.statusText}`);
323
+ }
324
+ return response.arrayBuffer();
325
+ }
280
326
  }
281
327
  export default templateClient;
@@ -17,14 +17,19 @@ class templateClient {
17
17
  }
18
18
  async request(method, command, body) {
19
19
  const url = `${this.baseURL}${command}`;
20
+ const headers = {
21
+ Accept: 'application/json',
22
+ Authorization: `Bearer apikey-${this.apiKey}`
23
+ };
20
24
  const options = {
21
25
  method,
22
- headers: {
23
- 'Content-Type': 'application/json',
24
- Authorization: `Bearer apikey-${this.apiKey}`
25
- },
26
- body: body ? JSON.stringify(body) : '{}'
26
+ headers
27
27
  };
28
+ // Avoid GET bodies (they're non-standard and can break under some proxies).
29
+ if (method !== 'GET' && body !== undefined) {
30
+ headers['Content-Type'] = 'application/json';
31
+ options.body = JSON.stringify(body);
32
+ }
28
33
  // console.log(JSON.stringify({ options, url }));
29
34
  const response = await fetch(url, options);
30
35
  const j = await response.json();
@@ -217,36 +222,46 @@ class templateClient {
217
222
  this.validateSender(data.sender);
218
223
  return this.post('/api/v1/form/template', data);
219
224
  }
220
- async sendFormMessage(data) {
221
- if (!data.form_key && !data.formid) {
222
- throw new Error('Invalid request body; formid or form_key required');
225
+ async storeFormRecipient(data) {
226
+ if (!data.domain) {
227
+ throw new Error('Missing domain');
223
228
  }
224
- if (!data.form_key && !data.domain) {
225
- throw new Error('Invalid request body; domain required when sending by formid');
229
+ if (!data.idname) {
230
+ throw new Error('Missing recipient identifier');
231
+ }
232
+ if (!data.email) {
233
+ throw new Error('Missing recipient email');
234
+ }
235
+ const parsed = email_addresses_1.default.parseOneAddress(data.email);
236
+ if (!parsed || !parsed.address) {
237
+ throw new Error('Invalid recipient email address');
238
+ }
239
+ return this.post('/api/v1/form/recipient', data);
240
+ }
241
+ async sendFormMessage(data) {
242
+ if (!data._mm_form_key) {
243
+ throw new Error('Invalid request body; _mm_form_key required');
226
244
  }
227
245
  const fields = data.fields || {};
228
246
  const baseFields = {
229
- formid: data.formid,
230
- form_key: data.form_key,
231
- secret: data.secret,
232
- recipient: data.recipient,
233
- domain: data.domain,
234
- locale: data.locale,
235
- vars: data.vars || {},
236
- replyTo: data.replyTo,
247
+ _mm_form_key: data._mm_form_key,
248
+ _mm_locale: data._mm_locale,
249
+ _mm_recipients: data._mm_recipients,
237
250
  ...fields
238
251
  };
239
252
  if (data.attachments && data.attachments.length > 0) {
240
- const { formData } = this.createAttachmentPayload(data.attachments);
253
+ const normalized = data.attachments.map((attachment, idx) => {
254
+ const field = attachment.field || `_mm_file${idx + 1}`;
255
+ if (!field.startsWith('_mm_file')) {
256
+ throw new Error('Form attachments must use multipart field names starting with _mm_file');
257
+ }
258
+ return { ...attachment, field };
259
+ });
260
+ const { formData } = this.createAttachmentPayload(normalized);
241
261
  this.appendFields(formData, {
242
- formid: data.formid,
243
- form_key: data.form_key,
244
- secret: data.secret,
245
- recipient: data.recipient,
246
- domain: data.domain,
247
- locale: data.locale,
248
- vars: JSON.stringify(data.vars || {}),
249
- replyTo: data.replyTo
262
+ _mm_form_key: data._mm_form_key,
263
+ _mm_locale: data._mm_locale,
264
+ _mm_recipients: data._mm_recipients
250
265
  });
251
266
  this.appendFields(formData, fields);
252
267
  return this.postFormData('/api/v1/form/message', formData);
@@ -282,5 +297,36 @@ class templateClient {
282
297
  });
283
298
  return this.postFormData('/api/v1/assets', formData);
284
299
  }
300
+ async getSwaggerSpec() {
301
+ return this.get('/api/swagger');
302
+ }
303
+ async fetchPublicAsset(domain, assetPath, viaApiBase = false) {
304
+ if (!domain) {
305
+ throw new Error('domain is required');
306
+ }
307
+ if (!assetPath) {
308
+ throw new Error('assetPath is required');
309
+ }
310
+ const cleanedPath = assetPath
311
+ .split('/')
312
+ .filter(Boolean)
313
+ .map((segment) => encodeURIComponent(segment))
314
+ .join('/');
315
+ if (!cleanedPath) {
316
+ throw new Error('assetPath is required');
317
+ }
318
+ const prefix = viaApiBase ? '/api/asset' : '/asset';
319
+ const url = `${this.baseURL}${prefix}/${encodeURIComponent(domain)}/${cleanedPath}`;
320
+ const response = await fetch(url, {
321
+ method: 'GET',
322
+ headers: {
323
+ Accept: '*/*'
324
+ }
325
+ });
326
+ if (!response.ok) {
327
+ throw new Error(`FETCH FAILED: ${response.status} ${response.statusText}`);
328
+ }
329
+ return response.arrayBuffer();
330
+ }
285
331
  }
286
332
  exports.default = templateClient;
@@ -31,7 +31,7 @@ function resolveCssPath(cssPath) {
31
31
  }
32
32
  return node_path_1.default.isAbsolute(cssPath) ? cssPath : node_path_1.default.join(process.cwd(), cssPath);
33
33
  }
34
- function inlineIncludes(content, baseDir, srcRoot, stack) {
34
+ function inlineIncludes(content, baseDir, srcRoot, normalizedSrcRoot, stack) {
35
35
  const includeExp = /\{%\s*include\s+['"]([^'"]+)['"][^%]*%\}/g;
36
36
  return content.replace(includeExp, (_match, includePath) => {
37
37
  const cleaned = includePath.replace(/^\/+/, '');
@@ -41,12 +41,18 @@ function inlineIncludes(content, baseDir, srcRoot, stack) {
41
41
  throw new Error(`Include not found: ${includePath}`);
42
42
  }
43
43
  const resolved = node_fs_1.default.realpathSync(found);
44
+ if (!resolved.startsWith(normalizedSrcRoot)) {
45
+ throw new Error(`Include path escapes template root: ${includePath}`);
46
+ }
47
+ if (!node_fs_1.default.statSync(resolved).isFile()) {
48
+ throw new Error(`Include is not a file: ${includePath}`);
49
+ }
44
50
  if (stack.has(resolved)) {
45
51
  throw new Error(`Circular include detected for ${includePath}`);
46
52
  }
47
53
  stack.add(resolved);
48
54
  const raw = node_fs_1.default.readFileSync(resolved, 'utf8');
49
- const inlined = inlineIncludes(raw, node_path_1.default.dirname(resolved), srcRoot, stack);
55
+ const inlined = inlineIncludes(raw, node_path_1.default.dirname(resolved), srcRoot, normalizedSrcRoot, stack);
50
56
  stack.delete(resolved);
51
57
  return inlined;
52
58
  });
@@ -95,12 +101,14 @@ function process_template(tplname, writeOutput = true) {
95
101
  console.log(`Processing template: ${tplname}`);
96
102
  try {
97
103
  const srcRoot = resolvePathRoot(cfg.src_dir);
104
+ const resolvedSrcRoot = node_fs_1.default.realpathSync(srcRoot);
105
+ const normalizedSrcRoot = resolvedSrcRoot.endsWith(node_path_1.default.sep) ? resolvedSrcRoot : resolvedSrcRoot + node_path_1.default.sep;
98
106
  const templateFile = node_path_1.default.join(srcRoot, `${tplname}.njk`);
99
107
  // 1) Resolve template inheritance
100
108
  const mergedTemplate = cfg.env.renderString(`{% process_layout "${tplname}.njk" %}`, {});
101
109
  // 1.5) Inline partials/includes so the server doesn't need a loader
102
110
  const mergedWithPartials = cfg.inline_includes
103
- ? inlineIncludes(mergedTemplate, node_path_1.default.dirname(templateFile), srcRoot, new Set())
111
+ ? inlineIncludes(mergedTemplate, node_path_1.default.dirname(templateFile), srcRoot, normalizedSrcRoot, new Set())
104
112
  : mergedTemplate;
105
113
  // 2) Protect variables/flow
106
114
  const protectedTemplate = cfg.env.filters.protect_variables(mergedWithPartials);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic-client",
3
- "version": "1.0.25",
3
+ "version": "1.0.28",
4
4
  "description": "Client library for mail-magic",
5
5
  "main": "dist/cjs/mail-magic-client.js",
6
6
  "types": "dist/cjs/mail-magic-client.d.ts",