@technomoron/mail-magic 1.0.14 → 1.0.16
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 +9 -0
- package/README.md +35 -13
- package/TUTORIAL.MD +1 -1
- package/dist/api/assets.js +34 -39
- package/dist/api/forms.js +1 -1
- package/dist/api/mailer.js +1 -1
- package/dist/index.js +60 -54
- package/dist/models/init.js +4 -2
- package/dist/store/envloader.js +17 -0
- package/package.json +6 -20
package/CHANGES
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
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
|
+
|
|
1
10
|
Version 1.0.14 (2026-02-01)
|
|
2
11
|
|
|
3
12
|
- Consolidated the API domain/user assertion into a shared helper to keep
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @technomoron/mail-magic
|
|
2
2
|
|
|
3
|
-
Mail Magic is a TypeScript service for managing, templating, and delivering transactional emails. It exposes a small
|
|
3
|
+
Mail Magic is a TypeScript service for managing, templating, and delivering transactional emails. It exposes a small
|
|
4
|
+
REST API built on `@technomoron/api-server-base`, persists data with Sequelize/SQLite, and renders outbound messages
|
|
5
|
+
with Nunjucks templates.
|
|
4
6
|
|
|
5
7
|
## Features
|
|
6
8
|
|
|
@@ -16,7 +18,8 @@ Mail Magic is a TypeScript service for managing, templating, and delivering tran
|
|
|
16
18
|
1. Clone the repository: `git clone git@github.com:technomoron/mail-magic.git`
|
|
17
19
|
2. Install dependencies: `npm install`
|
|
18
20
|
3. Create your environment file: copy `.env-dist` to `.env` and adjust values
|
|
19
|
-
4. Populate the config directory (defaults to `./data/`; see `config-example/` for a reference layout). You can point
|
|
21
|
+
4. Populate the config directory (defaults to `./data/`; see `config-example/` for a reference layout). You can point
|
|
22
|
+
`CONFIG_PATH` at `./config` to use the bundled sample data.
|
|
20
23
|
5. Build the project: `npm run build`
|
|
21
24
|
6. Start the API server: `npm run start`
|
|
22
25
|
|
|
@@ -24,22 +27,38 @@ During development you can run `npm run dev` for a watch mode that recompiles on
|
|
|
24
27
|
|
|
25
28
|
## Configuration
|
|
26
29
|
|
|
27
|
-
- **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
- **
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
- **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API
|
|
31
|
+
host/port, the config directory path, database options, and `ADMIN_ENABLED`/`ADMIN_APP_PATH` to control the admin
|
|
32
|
+
UI/API.
|
|
33
|
+
- **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template
|
|
34
|
+
assets. Each domain now lives directly under the config root (for example `data/example.com/form-template/…`). Use an
|
|
35
|
+
absolute path or a relative one like `../data` when you want the config outside the repo. Review `config-example/` for
|
|
36
|
+
the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks
|
|
37
|
+
templates.
|
|
38
|
+
- **Database** defaults to SQLite (`maildata.db`). You can switch dialects by updating the environment options if your
|
|
39
|
+
deployment requires another database.
|
|
40
|
+
- **Uploads** default to `<CONFIG_PATH>/<domain>/uploads` via `UPLOAD_PATH=./{domain}/uploads`. Set a fixed path if you
|
|
41
|
+
prefer a shared upload directory.
|
|
42
|
+
|
|
43
|
+
When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a
|
|
44
|
+
restart.
|
|
33
45
|
|
|
34
46
|
### Admin UI
|
|
35
47
|
|
|
36
|
-
The server mounts the admin UI at `/` when the `@technomoron/mail-magic-admin` package
|
|
48
|
+
The server mounts the admin UI at `/` only when `ADMIN_ENABLED` is true and the `@technomoron/mail-magic-admin` package
|
|
49
|
+
is installed. You can point `ADMIN_APP_PATH` at a dist folder (or its parent) to override the package-provided build.
|
|
50
|
+
The admin API module is loaded from the admin package as well. This is a placeholder Vue app today, but it is already
|
|
51
|
+
wired so future admin features can live there without changing the server routing.
|
|
37
52
|
|
|
38
53
|
### Template assets and inline resources
|
|
39
54
|
|
|
40
|
-
- Keep any non-inline files (images, attachments, etc.) under `<CONFIG_PATH>/<domain>/assets`. Mail Magic rewrites
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
- Keep any non-inline files (images, attachments, etc.) under `<CONFIG_PATH>/<domain>/assets`. Mail Magic rewrites
|
|
56
|
+
`asset('logo.png')` using `ASSET_ROUTE` (default `/asset`) and a base URL from `ASSET_PUBLIC_BASE` (or `API_URL` if
|
|
57
|
+
unset). The default output looks like `http://localhost:3776/asset/<domain>/logo.png`.
|
|
58
|
+
- Pass `true` as the second argument when you want to embed a file as an inline CID attachment:
|
|
59
|
+
`asset('logo.png', true)` stores the file in Nodemailer and rewrites the HTML to reference `cid:logo.png`.
|
|
60
|
+
- Avoid mixing template-type folders for assets; everything that should be linked externally belongs in the shared
|
|
61
|
+
`<domain>/assets` tree so it can be served for both form and transactional templates.
|
|
43
62
|
|
|
44
63
|
## API Overview
|
|
45
64
|
|
|
@@ -50,7 +69,10 @@ The server mounts the admin UI at `/` when the `@technomoron/mail-magic-admin` p
|
|
|
50
69
|
| POST | `/v1/form/template` | Store or update a form submission template |
|
|
51
70
|
| POST | `/v1/form/message` | Submit a form payload and deliver the email |
|
|
52
71
|
|
|
53
|
-
All
|
|
72
|
+
All routes are mounted under `API_BASE_PATH` (default `/api`), so the full path is typically `/api/v1/...`.
|
|
73
|
+
|
|
74
|
+
All authenticated routes expect an API token associated with a configured user. Attachments can be uploaded alongside
|
|
75
|
+
the `/v1/tx/message` request and are forwarded by Nodemailer.
|
|
54
76
|
|
|
55
77
|
## Available Scripts
|
|
56
78
|
|
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
|
|
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
|
|
package/dist/api/assets.js
CHANGED
|
@@ -1,10 +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 { assert_domain_and_user } from './auth.js';
|
|
5
4
|
import { api_form } from '../models/form.js';
|
|
6
5
|
import { api_txmail } from '../models/txmail.js';
|
|
7
6
|
import { decodeComponent, sendFileAsync } from '../util.js';
|
|
7
|
+
import { assert_domain_and_user } from './auth.js';
|
|
8
8
|
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
9
9
|
const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
10
10
|
export class AssetAPI extends ApiModule {
|
|
@@ -132,22 +132,37 @@ export class AssetAPI extends ApiModule {
|
|
|
132
132
|
await this.moveUploadedFiles(rawFiles, candidate);
|
|
133
133
|
return [200, { Status: 'OK' }];
|
|
134
134
|
}
|
|
135
|
-
|
|
136
|
-
|
|
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);
|
|
137
149
|
if (!domain || !DOMAIN_PATTERN.test(domain)) {
|
|
138
|
-
|
|
150
|
+
res.status(404).end();
|
|
151
|
+
return;
|
|
139
152
|
}
|
|
140
|
-
const rawPath =
|
|
153
|
+
const rawPath = typeof req?.params?.[0] === 'string' ? req.params[0] : '';
|
|
141
154
|
const segments = rawPath
|
|
142
155
|
.split('/')
|
|
143
156
|
.filter(Boolean)
|
|
144
157
|
.map((segment) => decodeComponent(segment));
|
|
145
158
|
if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
|
|
146
|
-
|
|
159
|
+
res.status(404).end();
|
|
160
|
+
return;
|
|
147
161
|
}
|
|
148
|
-
const assetsRoot = path.join(
|
|
162
|
+
const assetsRoot = path.join(server.storage.configpath, domain, 'assets');
|
|
149
163
|
if (!fs.existsSync(assetsRoot)) {
|
|
150
|
-
|
|
164
|
+
res.status(404).end();
|
|
165
|
+
return;
|
|
151
166
|
}
|
|
152
167
|
const resolvedRoot = fs.realpathSync(assetsRoot);
|
|
153
168
|
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
@@ -155,56 +170,36 @@ export class AssetAPI extends ApiModule {
|
|
|
155
170
|
try {
|
|
156
171
|
const stats = await fs.promises.stat(candidate);
|
|
157
172
|
if (!stats.isFile()) {
|
|
158
|
-
|
|
173
|
+
res.status(404).end();
|
|
174
|
+
return;
|
|
159
175
|
}
|
|
160
176
|
}
|
|
161
177
|
catch {
|
|
162
|
-
|
|
178
|
+
res.status(404).end();
|
|
179
|
+
return;
|
|
163
180
|
}
|
|
164
181
|
let realCandidate;
|
|
165
182
|
try {
|
|
166
183
|
realCandidate = await fs.promises.realpath(candidate);
|
|
167
184
|
}
|
|
168
185
|
catch {
|
|
169
|
-
|
|
186
|
+
res.status(404).end();
|
|
187
|
+
return;
|
|
170
188
|
}
|
|
171
189
|
if (!realCandidate.startsWith(normalizedRoot)) {
|
|
172
|
-
|
|
190
|
+
res.status(404).end();
|
|
191
|
+
return;
|
|
173
192
|
}
|
|
174
|
-
const { res } = apiReq;
|
|
175
|
-
const originalStatus = res.status.bind(res);
|
|
176
|
-
const originalJson = res.json.bind(res);
|
|
177
|
-
res.status = ((code) => (res.headersSent ? res : originalStatus(code)));
|
|
178
|
-
res.json = ((body) => (res.headersSent ? res : originalJson(body)));
|
|
179
193
|
res.type(path.extname(realCandidate));
|
|
180
194
|
res.set('Cache-Control', 'public, max-age=300');
|
|
181
195
|
try {
|
|
182
196
|
await sendFileAsync(res, realCandidate);
|
|
183
197
|
}
|
|
184
198
|
catch (err) {
|
|
185
|
-
|
|
199
|
+
server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
|
|
186
200
|
if (!res.headersSent) {
|
|
187
|
-
|
|
201
|
+
res.status(500).end();
|
|
188
202
|
}
|
|
189
203
|
}
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
defineRoutes() {
|
|
193
|
-
const route = this.server.storage.env.ASSET_ROUTE;
|
|
194
|
-
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
195
|
-
return [
|
|
196
|
-
{
|
|
197
|
-
method: 'post',
|
|
198
|
-
path: '/v1/assets',
|
|
199
|
-
handler: (apiReq) => this.postAssets(apiReq),
|
|
200
|
-
auth: { type: 'yes', req: 'any' }
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
method: 'get',
|
|
204
|
-
path: `${normalizedRoute}/:domain/*`,
|
|
205
|
-
handler: (apiReq) => this.getAsset(apiReq),
|
|
206
|
-
auth: { type: 'none', req: 'any' }
|
|
207
|
-
}
|
|
208
|
-
];
|
|
209
|
-
}
|
|
204
|
+
};
|
|
210
205
|
}
|
package/dist/api/forms.js
CHANGED
|
@@ -2,10 +2,10 @@ import path from 'path';
|
|
|
2
2
|
import { ApiModule, ApiError } from '@technomoron/api-server-base';
|
|
3
3
|
import emailAddresses from 'email-addresses';
|
|
4
4
|
import nunjucks from 'nunjucks';
|
|
5
|
-
import { assert_domain_and_user } from './auth.js';
|
|
6
5
|
import { api_domain } from '../models/domain.js';
|
|
7
6
|
import { api_form } from '../models/form.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);
|
package/dist/api/mailer.js
CHANGED
|
@@ -2,9 +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 { assert_domain_and_user } from './auth.js';
|
|
6
5
|
import { api_txmail } from '../models/txmail.js';
|
|
7
6
|
import { buildRequestMeta } from '../util.js';
|
|
7
|
+
import { assert_domain_and_user } from './auth.js';
|
|
8
8
|
export class MailerAPI extends ApiModule {
|
|
9
9
|
//
|
|
10
10
|
// Validate and return the parsed email address
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
60
|
-
const require = createRequire(import.meta.url);
|
|
79
|
+
async function enableAdminFeatures(server, store) {
|
|
61
80
|
try {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
}
|
package/dist/models/init.js
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/store/envloader.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.0.16",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,14 +17,6 @@
|
|
|
17
17
|
"url": "git+https://github.com/technomoron/mail-magic.git",
|
|
18
18
|
"directory": "packages/mail-magic"
|
|
19
19
|
},
|
|
20
|
-
"pnpm": {
|
|
21
|
-
"onlyBuiltDependencies": [
|
|
22
|
-
"core-js",
|
|
23
|
-
"@scarf/scarf",
|
|
24
|
-
"esbuild",
|
|
25
|
-
"sqlite3"
|
|
26
|
-
]
|
|
27
|
-
},
|
|
28
20
|
"scripts": {
|
|
29
21
|
"start": "node dist/index.js",
|
|
30
22
|
"dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --watch 'config/**/*.*' --watch '.env' --exec 'tsx' src/index.ts",
|
|
@@ -35,12 +27,12 @@
|
|
|
35
27
|
"test": "vitest run",
|
|
36
28
|
"test:watch": "vitest",
|
|
37
29
|
"scrub": "rm -rf ./node_modules/ ./dist/ pnpm-lock.yaml",
|
|
38
|
-
"lint": "eslint --no-error-on-unmatched-pattern --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue,.json ./",
|
|
39
|
-
"lintfix": "eslint --fix --no-error-on-unmatched-pattern --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue,.json ./",
|
|
40
|
-
"pretty": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,md}\"",
|
|
30
|
+
"lint": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs --no-error-on-unmatched-pattern --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue,.json ./",
|
|
31
|
+
"lintfix": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs --fix --no-error-on-unmatched-pattern --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue,.json ./",
|
|
32
|
+
"pretty": "node ../../node_modules/prettier/bin/prettier.cjs --config ../../.prettierrc --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,md}\"",
|
|
41
33
|
"format": "npm run lintfix && npm run pretty",
|
|
42
34
|
"cleanbuild": "rm -rf ./dist/ && npm run format && npm run build",
|
|
43
|
-
"lintconfig": "node lintconfig.cjs"
|
|
35
|
+
"lintconfig": "node ../../lintconfig.cjs"
|
|
44
36
|
},
|
|
45
37
|
"keywords": [],
|
|
46
38
|
"author": "Bjørn Erik Jacobsen",
|
|
@@ -66,19 +58,13 @@
|
|
|
66
58
|
"zod": "^4.1.5"
|
|
67
59
|
},
|
|
68
60
|
"devDependencies": {
|
|
61
|
+
"@types/express": "^4.17.21",
|
|
69
62
|
"@types/html-to-text": "^9.0.4",
|
|
70
63
|
"@types/nodemailer": "^6.4.19",
|
|
71
64
|
"@types/nunjucks": "^3.2.6",
|
|
72
65
|
"@types/supertest": "^6.0.3",
|
|
73
|
-
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
|
74
|
-
"@typescript-eslint/parser": "^8.50.1",
|
|
75
|
-
"eslint": "^9.39.2",
|
|
76
|
-
"eslint-config-prettier": "^10.1.8",
|
|
77
|
-
"eslint-plugin-import": "^2.32.0",
|
|
78
|
-
"jsonc-eslint-parser": "^2.4.1",
|
|
79
66
|
"mailparser": "^3.9.1",
|
|
80
67
|
"nodemon": "^3.1.10",
|
|
81
|
-
"prettier": "^3.7.4",
|
|
82
68
|
"smtp-server": "^3.18.0",
|
|
83
69
|
"supertest": "^7.1.4",
|
|
84
70
|
"tsx": "^4.20.5",
|