@technomoron/mail-magic 1.0.13 → 1.0.15

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,17 @@
1
+ Version 1.0.15 (2026-02-01)
2
+
3
+ - Made the optional admin UI/API opt-in via `ADMIN_ENABLED`, delegating admin
4
+ registration to the `@technomoron/mail-magic-admin` package with an
5
+ override path via `ADMIN_APP_PATH`.
6
+ - Serve public assets directly from `ASSET_ROUTE` (outside the API base path)
7
+ and allow asset URLs to use `ASSET_PUBLIC_BASE` when rendering templates.
8
+ - Added coverage for asset base URL overrides in the test suite.
9
+
10
+ Version 1.0.14 (2026-02-01)
11
+
12
+ - Consolidated the API domain/user assertion into a shared helper to keep
13
+ validation logic consistent across modules.
14
+
1
15
  Version 1.0.13 (2026-01-31)
2
16
 
3
17
  - Fixed the per-package release script name and tag format used to trigger
package/README.md CHANGED
@@ -24,7 +24,7 @@ During development you can run `npm run dev` for a watch mode that recompiles on
24
24
 
25
25
  ## Configuration
26
26
 
27
- - **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API host/port, the config directory path, and database options.
27
+ - **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API host/port, the config directory path, database options, and `ADMIN_ENABLED`/`ADMIN_APP_PATH` to control the admin UI/API.
28
28
  - **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template assets. Each domain now lives directly under the config root (for example `data/example.com/form-template/…`). Use an absolute path or a relative one like `../data` when you want the config outside the repo. Review `config-example/` for the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks templates.
29
29
  - **Database** defaults to SQLite (`maildata.db`). You can switch dialects by updating the environment options if your deployment requires another database.
30
30
  - **Uploads** default to `<CONFIG_PATH>/<domain>/uploads` via `UPLOAD_PATH=./{domain}/uploads`. Set a fixed path if you prefer a shared upload directory.
@@ -33,11 +33,11 @@ When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refres
33
33
 
34
34
  ### Admin UI
35
35
 
36
- The server mounts the admin UI at `/` when the `@technomoron/mail-magic-admin` package is installed. This is a placeholder Vue app today, but it is already wired so future admin features can live there without changing the server routing.
36
+ The server mounts the admin UI at `/` only when `ADMIN_ENABLED` is true and the `@technomoron/mail-magic-admin` package is installed. You can point `ADMIN_APP_PATH` at a dist folder (or its parent) to override the package-provided build. The admin API module is loaded from the admin package as well. This is a placeholder Vue app today, but it is already wired so future admin features can live there without changing the server routing.
37
37
 
38
38
  ### Template assets and inline resources
39
39
 
40
- - Keep any non-inline files (images, attachments, etc.) under `<CONFIG_PATH>/<domain>/assets`. Mail Magic rewrites `asset('logo.png')` to the public route `/asset/<domain>/logo.png` (or whatever you set via `ASSET_ROUTE`).
40
+ - Keep any non-inline files (images, attachments, etc.) under `<CONFIG_PATH>/<domain>/assets`. Mail Magic rewrites `asset('logo.png')` using `ASSET_ROUTE` (default `/asset`) and a base URL from `ASSET_PUBLIC_BASE` (or `API_URL` if unset). The default output looks like `http://localhost:3776/asset/<domain>/logo.png`.
41
41
  - Pass `true` as the second argument when you want to embed a file as an inline CID attachment: `asset('logo.png', true)` stores the file in Nodemailer and rewrites the HTML to reference `cid:logo.png`.
42
42
  - Avoid mixing template-type folders for assets; everything that should be linked externally belongs in the shared `<domain>/assets` tree so it can be served for both form and transactional templates.
43
43
 
@@ -50,6 +50,8 @@ The server mounts the admin UI at `/` when the `@technomoron/mail-magic-admin` p
50
50
  | POST | `/v1/form/template` | Store or update a form submission template |
51
51
  | POST | `/v1/form/message` | Submit a form payload and deliver the email |
52
52
 
53
+ All routes are mounted under `API_BASE_PATH` (default `/api`), so the full path is typically `/api/v1/...`.
54
+
53
55
  All authenticated routes expect an API token associated with a configured user. Attachments can be uploaded alongside the `/v1/tx/message` request and are forwarded by Nodemailer.
54
56
 
55
57
  ## Available Scripts
package/TUTORIAL.MD CHANGED
@@ -58,7 +58,7 @@ myorg-config/
58
58
 
59
59
  > **Tip:** If you want to share partials between templates, keep file names aligned (e.g. identical `header.njk` content under both `form-template/partials/` and `tx-template/partials/`).
60
60
 
61
- > **Assets vs inline:** Any file you want to serve as an external URL must live under `myorg.com/assets/`. The template helper `asset('logo.png')` will become `/asset/myorg.com/logo.png`. Use `asset('logo.png', true)` when you need the file embedded as a CID attachment instead.
61
+ > **Assets vs inline:** Any file you want to serve as an external URL must live under `myorg.com/assets/`. The template helper `asset('logo.png')` will become `http://localhost:3776/asset/myorg.com/logo.png` by default. You can change the base via `ASSET_PUBLIC_BASE` (or `API_URL`) and the path via `ASSET_ROUTE`. Use `asset('logo.png', true)` when you need the file embedded as a CID attachment instead.
62
62
 
63
63
  ---
64
64
 
@@ -1,11 +1,10 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { ApiError, ApiModule } from '@technomoron/api-server-base';
4
- import { api_domain } from '../models/domain.js';
5
4
  import { api_form } from '../models/form.js';
6
5
  import { api_txmail } from '../models/txmail.js';
7
- import { api_user } from '../models/user.js';
8
6
  import { decodeComponent, sendFileAsync } from '../util.js';
7
+ import { assert_domain_and_user } from './auth.js';
9
8
  const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
10
9
  const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
11
10
  export class AssetAPI extends ApiModule {
@@ -21,25 +20,6 @@ export class AssetAPI extends ApiModule {
21
20
  }
22
21
  return '';
23
22
  }
24
- async assertDomainAndUser(apireq) {
25
- const domainName = this.getBodyValue(apireq.req.body ?? {}, 'domain');
26
- if (!domainName) {
27
- throw new ApiError({ code: 401, message: 'Missing domain' });
28
- }
29
- const user = await api_user.findOne({ where: { token: apireq.token } });
30
- if (!user) {
31
- throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
32
- }
33
- const dbdomain = await api_domain.findOne({ where: { name: domainName } });
34
- if (!dbdomain) {
35
- throw new ApiError({ code: 401, message: `Unable to look up the domain ${domainName}` });
36
- }
37
- if (dbdomain.user_id !== user.user_id) {
38
- throw new ApiError({ code: 403, message: `Domain ${domainName} is not owned by this user` });
39
- }
40
- apireq.domain = dbdomain;
41
- apireq.user = user;
42
- }
43
23
  normalizeSubdir(value) {
44
24
  if (!value) {
45
25
  return '';
@@ -129,7 +109,7 @@ export class AssetAPI extends ApiModule {
129
109
  throw new ApiError({ code: 400, message: 'templateType must be "tx" or "form"' });
130
110
  }
131
111
  async postAssets(apireq) {
132
- await this.assertDomainAndUser(apireq);
112
+ await assert_domain_and_user(apireq);
133
113
  const rawFiles = Array.isArray(apireq.req.files) ? apireq.req.files : [];
134
114
  if (!rawFiles.length) {
135
115
  throw new ApiError({ code: 400, message: 'No files uploaded' });
@@ -152,22 +132,37 @@ export class AssetAPI extends ApiModule {
152
132
  await this.moveUploadedFiles(rawFiles, candidate);
153
133
  return [200, { Status: 'OK' }];
154
134
  }
155
- async getAsset(apiReq) {
156
- const domain = decodeComponent(apiReq.req.params.domain);
135
+ defineRoutes() {
136
+ return [
137
+ {
138
+ method: 'post',
139
+ path: '/v1/assets',
140
+ handler: (apiReq) => this.postAssets(apiReq),
141
+ auth: { type: 'yes', req: 'any' }
142
+ }
143
+ ];
144
+ }
145
+ }
146
+ export function createAssetHandler(server) {
147
+ return async (req, res) => {
148
+ const domain = decodeComponent(req?.params?.domain);
157
149
  if (!domain || !DOMAIN_PATTERN.test(domain)) {
158
- throw new ApiError({ code: 404, message: 'Asset not found' });
150
+ res.status(404).end();
151
+ return;
159
152
  }
160
- const rawPath = apiReq.req.params[0] ?? '';
153
+ const rawPath = typeof req?.params?.[0] === 'string' ? req.params[0] : '';
161
154
  const segments = rawPath
162
155
  .split('/')
163
156
  .filter(Boolean)
164
157
  .map((segment) => decodeComponent(segment));
165
158
  if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
166
- throw new ApiError({ code: 404, message: 'Asset not found' });
159
+ res.status(404).end();
160
+ return;
167
161
  }
168
- const assetsRoot = path.join(this.server.storage.configpath, domain, 'assets');
162
+ const assetsRoot = path.join(server.storage.configpath, domain, 'assets');
169
163
  if (!fs.existsSync(assetsRoot)) {
170
- throw new ApiError({ code: 404, message: 'Asset not found' });
164
+ res.status(404).end();
165
+ return;
171
166
  }
172
167
  const resolvedRoot = fs.realpathSync(assetsRoot);
173
168
  const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
@@ -175,56 +170,36 @@ export class AssetAPI extends ApiModule {
175
170
  try {
176
171
  const stats = await fs.promises.stat(candidate);
177
172
  if (!stats.isFile()) {
178
- throw new ApiError({ code: 404, message: 'Asset not found' });
173
+ res.status(404).end();
174
+ return;
179
175
  }
180
176
  }
181
177
  catch {
182
- throw new ApiError({ code: 404, message: 'Asset not found' });
178
+ res.status(404).end();
179
+ return;
183
180
  }
184
181
  let realCandidate;
185
182
  try {
186
183
  realCandidate = await fs.promises.realpath(candidate);
187
184
  }
188
185
  catch {
189
- throw new ApiError({ code: 404, message: 'Asset not found' });
186
+ res.status(404).end();
187
+ return;
190
188
  }
191
189
  if (!realCandidate.startsWith(normalizedRoot)) {
192
- throw new ApiError({ code: 404, message: 'Asset not found' });
190
+ res.status(404).end();
191
+ return;
193
192
  }
194
- const { res } = apiReq;
195
- const originalStatus = res.status.bind(res);
196
- const originalJson = res.json.bind(res);
197
- res.status = ((code) => (res.headersSent ? res : originalStatus(code)));
198
- res.json = ((body) => (res.headersSent ? res : originalJson(body)));
199
193
  res.type(path.extname(realCandidate));
200
194
  res.set('Cache-Control', 'public, max-age=300');
201
195
  try {
202
196
  await sendFileAsync(res, realCandidate);
203
197
  }
204
198
  catch (err) {
205
- this.server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
199
+ server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
206
200
  if (!res.headersSent) {
207
- throw new ApiError({ code: 500, message: 'Failed to stream asset' });
201
+ res.status(500).end();
208
202
  }
209
203
  }
210
- return [200, null];
211
- }
212
- defineRoutes() {
213
- const route = this.server.storage.env.ASSET_ROUTE;
214
- const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
215
- return [
216
- {
217
- method: 'post',
218
- path: '/v1/assets',
219
- handler: (apiReq) => this.postAssets(apiReq),
220
- auth: { type: 'yes', req: 'any' }
221
- },
222
- {
223
- method: 'get',
224
- path: `${normalizedRoute}/:domain/*`,
225
- handler: (apiReq) => this.getAsset(apiReq),
226
- auth: { type: 'none', req: 'any' }
227
- }
228
- ];
229
- }
204
+ };
230
205
  }
@@ -0,0 +1,37 @@
1
+ import { ApiError } from '@technomoron/api-server-base';
2
+ import { api_domain } from '../models/domain.js';
3
+ import { api_user } from '../models/user.js';
4
+ function getBodyValue(body, ...keys) {
5
+ for (const key of keys) {
6
+ const value = body[key];
7
+ if (Array.isArray(value) && value.length > 0) {
8
+ return String(value[0]);
9
+ }
10
+ if (value !== undefined && value !== null) {
11
+ return String(value);
12
+ }
13
+ }
14
+ return '';
15
+ }
16
+ export async function assert_domain_and_user(apireq) {
17
+ const body = apireq.req.body ?? {};
18
+ const domain = getBodyValue(body, 'domain');
19
+ const locale = getBodyValue(body, 'locale');
20
+ if (!domain) {
21
+ throw new ApiError({ code: 401, message: 'Missing domain' });
22
+ }
23
+ const user = await api_user.findOne({ where: { token: apireq.token } });
24
+ if (!user) {
25
+ throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
26
+ }
27
+ const dbdomain = await api_domain.findOne({ where: { name: domain } });
28
+ if (!dbdomain) {
29
+ throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
30
+ }
31
+ if (dbdomain.user_id !== user.user_id) {
32
+ throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
33
+ }
34
+ apireq.domain = dbdomain;
35
+ apireq.user = user;
36
+ apireq.locale = locale || 'en';
37
+ }
package/dist/api/forms.js CHANGED
@@ -4,8 +4,8 @@ import emailAddresses from 'email-addresses';
4
4
  import nunjucks from 'nunjucks';
5
5
  import { api_domain } from '../models/domain.js';
6
6
  import { api_form } from '../models/form.js';
7
- import { api_user } from '../models/user.js';
8
7
  import { buildRequestMeta, normalizeSlug } from '../util.js';
8
+ import { assert_domain_and_user } from './auth.js';
9
9
  export class FormAPI extends ApiModule {
10
10
  validateEmail(email) {
11
11
  const parsed = emailAddresses.parseOneAddress(email);
@@ -14,28 +14,8 @@ export class FormAPI extends ApiModule {
14
14
  }
15
15
  return undefined;
16
16
  }
17
- async assertDomainAndUser(apireq) {
18
- const { domain, locale } = apireq.req.body;
19
- if (!domain) {
20
- throw new ApiError({ code: 401, message: 'Missing domain' });
21
- }
22
- const user = await api_user.findOne({ where: { token: apireq.token } });
23
- if (!user) {
24
- throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
25
- }
26
- const dbdomain = await api_domain.findOne({ where: { name: domain } });
27
- if (!dbdomain) {
28
- throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
29
- }
30
- if (dbdomain.user_id !== user.user_id) {
31
- throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
32
- }
33
- apireq.domain = dbdomain;
34
- apireq.locale = locale || 'en';
35
- apireq.user = user;
36
- }
37
17
  async postFormTemplate(apireq) {
38
- await this.assertDomainAndUser(apireq);
18
+ await assert_domain_and_user(apireq);
39
19
  const { template, sender = '', recipient = '', idname, subject = '', locale = '', secret = '' } = apireq.req.body;
40
20
  if (!template) {
41
21
  throw new ApiError({ code: 400, message: 'Missing template data' });
@@ -2,10 +2,9 @@ import { ApiModule, ApiError } from '@technomoron/api-server-base';
2
2
  import emailAddresses from 'email-addresses';
3
3
  import { convert } from 'html-to-text';
4
4
  import nunjucks from 'nunjucks';
5
- import { api_domain } from '../models/domain.js';
6
5
  import { api_txmail } from '../models/txmail.js';
7
- import { api_user } from '../models/user.js';
8
6
  import { buildRequestMeta } from '../util.js';
7
+ import { assert_domain_and_user } from './auth.js';
9
8
  export class MailerAPI extends ApiModule {
10
9
  //
11
10
  // Validate and return the parsed email address
@@ -38,29 +37,9 @@ export class MailerAPI extends ApiModule {
38
37
  });
39
38
  return { valid, invalid };
40
39
  }
41
- async assert_domain_and_user(apireq) {
42
- const { domain, locale } = apireq.req.body;
43
- if (!domain) {
44
- throw new ApiError({ code: 401, message: 'Missing domain' });
45
- }
46
- const user = await api_user.findOne({ where: { token: apireq.token } });
47
- if (!user) {
48
- throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
49
- }
50
- const dbdomain = await api_domain.findOne({ where: { name: domain } });
51
- if (!dbdomain) {
52
- throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
53
- }
54
- if (dbdomain.user_id !== user.user_id) {
55
- throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
56
- }
57
- apireq.domain = dbdomain;
58
- apireq.locale = locale || 'en';
59
- apireq.user = user;
60
- }
61
40
  // Store a template in the database
62
41
  async post_template(apireq) {
63
- await this.assert_domain_and_user(apireq);
42
+ await assert_domain_and_user(apireq);
64
43
  const { template, sender = '', name, subject = '', locale = '' } = apireq.req.body;
65
44
  if (!template) {
66
45
  throw new ApiError({ code: 400, message: 'Missing template data' });
@@ -100,8 +79,8 @@ export class MailerAPI extends ApiModule {
100
79
  }
101
80
  // Send a template using posted arguments.
102
81
  async post_send(apireq) {
103
- await this.assert_domain_and_user(apireq);
104
82
  const { name, rcpt, domain = '', locale = '', vars = {}, replyTo, reply_to, headers } = apireq.req.body;
83
+ await assert_domain_and_user(apireq);
105
84
  if (!name || !rcpt || !domain) {
106
85
  throw new ApiError({ code: 400, message: 'name/rcpt/domain required' });
107
86
  }
package/dist/index.js CHANGED
@@ -1,12 +1,23 @@
1
- import fs from 'node:fs';
2
- import { createRequire } from 'node:module';
3
- import path from 'node:path';
4
- import { fileURLToPath, pathToFileURL } from 'node:url';
5
- import { AssetAPI } from './api/assets.js';
1
+ import { pathToFileURL } from 'node:url';
2
+ import { AssetAPI, createAssetHandler } from './api/assets.js';
6
3
  import { FormAPI } from './api/forms.js';
7
4
  import { MailerAPI } from './api/mailer.js';
8
5
  import { mailApiServer } from './server.js';
9
6
  import { mailStore } from './store/store.js';
7
+ function normalizeRoute(value, fallback = '') {
8
+ if (!value) {
9
+ return fallback;
10
+ }
11
+ const trimmed = value.trim();
12
+ if (!trimmed) {
13
+ return fallback;
14
+ }
15
+ const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
16
+ if (withLeading === '/') {
17
+ return withLeading;
18
+ }
19
+ return withLeading.replace(/\/+$/, '');
20
+ }
10
21
  function buildServerConfig(store, overrides) {
11
22
  const env = store.env;
12
23
  return {
@@ -14,7 +25,7 @@ function buildServerConfig(store, overrides) {
14
25
  apiPort: env.API_PORT,
15
26
  uploadPath: store.getUploadStagingPath(),
16
27
  debug: env.DEBUG,
17
- apiBasePath: '',
28
+ apiBasePath: normalizeRoute(env.API_BASE_PATH, '/api'),
18
29
  swaggerEnabled: env.SWAGGER_ENABLED,
19
30
  swaggerPath: env.SWAGGER_PATH,
20
31
  ...overrides
@@ -22,9 +33,18 @@ function buildServerConfig(store, overrides) {
22
33
  }
23
34
  export async function createMailMagicServer(overrides = {}) {
24
35
  const store = await new mailStore().init();
36
+ if (typeof overrides.apiBasePath === 'string') {
37
+ store.env.API_BASE_PATH = overrides.apiBasePath;
38
+ }
25
39
  const config = buildServerConfig(store, overrides);
26
40
  const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
27
- mountAdminUi(server, store);
41
+ mountAssetRoute(server, store);
42
+ if (store.env.ADMIN_ENABLED) {
43
+ await enableAdminFeatures(server, store);
44
+ }
45
+ else {
46
+ store.print_debug('Admin UI/API disabled via ADMIN_ENABLED');
47
+ }
28
48
  return { server, store, env: store.env };
29
49
  }
30
50
  export async function startMailMagicServer(overrides = {}) {
@@ -56,58 +76,44 @@ const isDirectExecution = (() => {
56
76
  if (isDirectExecution) {
57
77
  void bootMailMagic();
58
78
  }
59
- function resolveAdminDist() {
60
- const require = createRequire(import.meta.url);
79
+ async function enableAdminFeatures(server, store) {
61
80
  try {
62
- const pkgPath = require.resolve('@technomoron/mail-magic-admin/package.json');
63
- const pkgDir = path.dirname(pkgPath);
64
- const distPath = path.join(pkgDir, 'dist');
65
- if (fs.existsSync(distPath)) {
66
- return distPath;
81
+ const mod = (await import('@technomoron/mail-magic-admin'));
82
+ if (typeof mod?.registerAdmin === 'function') {
83
+ await mod.registerAdmin(server, {
84
+ apiBasePath: normalizeRoute(store.env.API_BASE_PATH, '/api'),
85
+ assetRoute: normalizeRoute(store.env.ASSET_ROUTE, '/asset'),
86
+ appPath: store.env.ADMIN_APP_PATH,
87
+ logger: (message) => store.print_debug(message)
88
+ });
89
+ }
90
+ else if (mod?.AdminAPI) {
91
+ server.api(new mod.AdminAPI());
92
+ }
93
+ else {
94
+ store.print_debug('Admin features not exported from @technomoron/mail-magic-admin');
67
95
  }
68
96
  }
69
- catch {
70
- // ignore
71
- }
72
- const fallbackBase = path.dirname(fileURLToPath(import.meta.url));
73
- const fallback = path.resolve(fallbackBase, '..', '..', 'mail-magic-admin', 'dist');
74
- if (fs.existsSync(fallback)) {
75
- return fallback;
97
+ catch (err) {
98
+ store.print_debug(`Unable to load admin module: ${err instanceof Error ? err.message : String(err)}`);
76
99
  }
77
- return null;
78
100
  }
79
- function mountAdminUi(server, store) {
80
- const distPath = resolveAdminDist();
81
- if (!distPath) {
82
- store.print_debug('Admin UI not found, skipping static mount');
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)) {
83
111
  return;
84
112
  }
85
- const assetRoute = store.env.ASSET_ROUTE.startsWith('/') ? store.env.ASSET_ROUTE : `/${store.env.ASSET_ROUTE}`;
86
- const indexPath = path.join(distPath, 'index.html');
87
- const hasIndex = fs.existsSync(indexPath);
88
- server.app.get('*', (req, res, next) => {
89
- if (req.method !== 'GET') {
90
- next();
91
- return;
92
- }
93
- if (req.path.startsWith('/api') || req.path.startsWith(assetRoute)) {
94
- next();
95
- return;
96
- }
97
- const requestPath = req.path === '/' ? 'index.html' : req.path.replace(/^\//, '');
98
- const resolvedPath = path.resolve(distPath, requestPath);
99
- if (!resolvedPath.startsWith(`${distPath}${path.sep}`) && resolvedPath !== distPath) {
100
- next();
101
- return;
102
- }
103
- if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isFile()) {
104
- res.sendFile(resolvedPath);
105
- return;
106
- }
107
- if (!hasIndex) {
108
- next();
109
- return;
110
- }
111
- res.sendFile(indexPath);
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);
113
119
  }
@@ -35,7 +35,7 @@ function resolveAsset(basePath, domainName, assetName) {
35
35
  }
36
36
  function buildAssetUrl(baseUrl, route, domainName, assetPath) {
37
37
  const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
38
- const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
38
+ const normalizedRoute = route ? (route.startsWith('/') ? route : `/${route}`) : '';
39
39
  const encodedDomain = encodeURIComponent(domainName);
40
40
  const encodedPath = assetPath
41
41
  .split('/')
@@ -69,7 +69,8 @@ function extractAndReplaceAssets(html, opts) {
69
69
  throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
70
70
  }
71
71
  const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
72
- const assetUrl = buildAssetUrl(opts.apiUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
72
+ const baseUrl = opts.assetBaseUrl?.trim() ? opts.assetBaseUrl : opts.apiUrl;
73
+ const assetUrl = buildAssetUrl(baseUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
73
74
  return `src="${assetUrl}"`;
74
75
  });
75
76
  return { html: replacedHtml, assets };
@@ -106,6 +107,7 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
106
107
  basePath: baseConfigPath,
107
108
  domainName: domain.name,
108
109
  apiUrl: store.env.API_URL,
110
+ assetBaseUrl: store.env.ASSET_PUBLIC_BASE,
109
111
  assetRoute: store.env.ASSET_ROUTE
110
112
  });
111
113
  return { html, assets };
@@ -28,6 +28,14 @@ export const envOptions = defineEnvOptions({
28
28
  description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
29
29
  default: 'http://localhost:3776'
30
30
  },
31
+ API_BASE_PATH: {
32
+ description: 'Base path prefix for API routes',
33
+ default: '/api'
34
+ },
35
+ ASSET_PUBLIC_BASE: {
36
+ description: 'Public base URL for asset hosting (origin or origin + path)',
37
+ default: ''
38
+ },
31
39
  SWAGGER_ENABLED: {
32
40
  description: 'Enable the Swagger/OpenAPI endpoint',
33
41
  type: 'boolean',
@@ -37,6 +45,15 @@ export const envOptions = defineEnvOptions({
37
45
  description: 'Path to expose the Swagger/OpenAPI spec (default: /api/swagger when enabled)',
38
46
  default: ''
39
47
  },
48
+ ADMIN_ENABLED: {
49
+ description: 'Enable the optional admin UI and admin API module when available',
50
+ default: false,
51
+ type: 'boolean'
52
+ },
53
+ ADMIN_APP_PATH: {
54
+ description: 'Optional path to the admin UI dist directory (or its parent)',
55
+ default: ''
56
+ },
40
57
  ASSET_ROUTE: {
41
58
  description: 'Route prefix exposed for config assets',
42
59
  default: '/asset'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {