@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 +14 -0
- package/README.md +5 -3
- package/TUTORIAL.MD +1 -1
- package/dist/api/assets.js +35 -60
- package/dist/api/auth.js +37 -0
- package/dist/api/forms.js +2 -22
- package/dist/api/mailer.js +3 -24
- package/dist/index.js +60 -54
- package/dist/models/init.js +4 -2
- package/dist/store/envloader.js +17 -0
- package/package.json +1 -1
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
|
|
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')`
|
|
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
|
|
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,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
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
150
|
+
res.status(404).end();
|
|
151
|
+
return;
|
|
159
152
|
}
|
|
160
|
-
const rawPath =
|
|
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
|
-
|
|
159
|
+
res.status(404).end();
|
|
160
|
+
return;
|
|
167
161
|
}
|
|
168
|
-
const assetsRoot = path.join(
|
|
162
|
+
const assetsRoot = path.join(server.storage.configpath, domain, 'assets');
|
|
169
163
|
if (!fs.existsSync(assetsRoot)) {
|
|
170
|
-
|
|
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
|
-
|
|
173
|
+
res.status(404).end();
|
|
174
|
+
return;
|
|
179
175
|
}
|
|
180
176
|
}
|
|
181
177
|
catch {
|
|
182
|
-
|
|
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
|
-
|
|
186
|
+
res.status(404).end();
|
|
187
|
+
return;
|
|
190
188
|
}
|
|
191
189
|
if (!realCandidate.startsWith(normalizedRoot)) {
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
res.status(500).end();
|
|
208
202
|
}
|
|
209
203
|
}
|
|
210
|
-
|
|
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
|
}
|
package/dist/api/auth.js
ADDED
|
@@ -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
|
|
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' });
|
package/dist/api/mailer.js
CHANGED
|
@@ -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
|
|
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
|
|
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'
|