@technomoron/mail-magic 1.0.5 → 1.0.6
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 +11 -0
- package/README.md +6 -0
- package/TUTORIAL.MD +57 -55
- package/dist/api/assets.js +64 -0
- package/dist/index.js +3 -1
- package/dist/models/init.js +37 -28
- package/dist/store/envloader.js +4 -21
- package/dist/util.js +23 -0
- package/package.json +1 -1
- package/src/api/assets.ts +79 -0
- package/src/index.ts +3 -1
- package/src/models/init.ts +40 -40
- package/src/store/envloader.ts +4 -21
- package/src/util.ts +26 -0
package/CHANGES
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
Version 1.0.6 (2025-11-06)
|
|
2
|
+
|
|
3
|
+
- Added the dedicated asset API module so files stored under `config/<domain>/assets`
|
|
4
|
+
are served directly from `/asset/<domain>/<path>` (configurable via `ASSET_ROUTE`).
|
|
5
|
+
- Tightened the template preprocessor to require non-inline assets to live in the
|
|
6
|
+
domain `assets/` folder and automatically rewrite `asset('logo.png')` calls to the
|
|
7
|
+
public route.
|
|
8
|
+
- Clarified inline usage: `asset('logo.png', true)` continues to embed a CID-backed
|
|
9
|
+
attachment, while omitting the second argument keeps the file external and expects
|
|
10
|
+
it under `config/<domain>/assets`.
|
|
11
|
+
|
|
1
12
|
Version 1.0.5 (2025-10-29)
|
|
2
13
|
|
|
3
14
|
- Store compiled templates and assets under domain-rooted directories inside
|
package/README.md
CHANGED
|
@@ -29,6 +29,12 @@ During development you can run `npm run dev` for a watch mode that recompiles on
|
|
|
29
29
|
|
|
30
30
|
When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a restart.
|
|
31
31
|
|
|
32
|
+
### Template assets and inline resources
|
|
33
|
+
|
|
34
|
+
- Keep any non-inline files (images, attachments, etc.) under `config/<domain>/assets`. Mail Magic rewrites `asset('logo.png')` to the public route `/asset/<domain>/logo.png` (or whatever you set via `ASSET_ROUTE`).
|
|
35
|
+
- 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`.
|
|
36
|
+
- 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.
|
|
37
|
+
|
|
32
38
|
## API Overview
|
|
33
39
|
|
|
34
40
|
| Method | Path | Description |
|
package/TUTORIAL.MD
CHANGED
|
@@ -57,6 +57,8 @@ myorg-config/
|
|
|
57
57
|
|
|
58
58
|
> **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/`).
|
|
59
59
|
|
|
60
|
+
> **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
|
+
|
|
60
62
|
---
|
|
61
63
|
|
|
62
64
|
## 3. Seed users, domains, and templates with `init-data.json`
|
|
@@ -65,49 +67,49 @@ Create `${CONFIG_ROOT}/init-data.json` so the service can bootstrap the MyOrg us
|
|
|
65
67
|
|
|
66
68
|
```json
|
|
67
69
|
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
70
|
+
"user": [
|
|
71
|
+
{
|
|
72
|
+
"user_id": 10,
|
|
73
|
+
"idname": "myorg",
|
|
74
|
+
"token": "<generate-a-32-char-hex-token>",
|
|
75
|
+
"name": "MyOrg",
|
|
76
|
+
"email": "notifications@myorg.com"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"domain": [
|
|
80
|
+
{
|
|
81
|
+
"domain_id": 10,
|
|
82
|
+
"user_id": 10,
|
|
83
|
+
"name": "myorg.com",
|
|
84
|
+
"sender": "MyOrg Mailer <noreply@myorg.com>"
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"template": [
|
|
88
|
+
{
|
|
89
|
+
"template_id": 100,
|
|
90
|
+
"user_id": 10,
|
|
91
|
+
"domain_id": 10,
|
|
92
|
+
"name": "welcome",
|
|
93
|
+
"slug": "welcome",
|
|
94
|
+
"locale": "",
|
|
95
|
+
"filename": "welcome.njk",
|
|
96
|
+
"sender": "support@myorg.com",
|
|
97
|
+
"subject": "Welcome to MyOrg",
|
|
98
|
+
"template": ""
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"form": [
|
|
102
|
+
{
|
|
103
|
+
"form_id": 100,
|
|
104
|
+
"user_id": 10,
|
|
105
|
+
"domain_id": 10,
|
|
106
|
+
"idname": "contact",
|
|
107
|
+
"filename": "contact.njk",
|
|
108
|
+
"sender": "MyOrg Support <support@myorg.com>",
|
|
109
|
+
"recipient": "contact@myorg.com",
|
|
110
|
+
"subject": "New contact form submission"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
111
113
|
}
|
|
112
114
|
```
|
|
113
115
|
|
|
@@ -274,18 +276,18 @@ The inline flag (`true`) in `asset('logo.png', true)` tells Mail Magic to attach
|
|
|
274
276
|
1. Restart `mail-magic` (or run `npm run dev`) so it picks up the new `CONFIG_PATH`.
|
|
275
277
|
2. Confirm the bootstrap worked — the logs should mention importing user `myorg` and domain `myorg.com`.
|
|
276
278
|
3. Trigger a transactional email:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
279
|
+
```bash
|
|
280
|
+
curl -X POST http://localhost:3000/v1/tx/message \
|
|
281
|
+
-H 'Content-Type: application/json' \
|
|
282
|
+
-H 'X-API-Token: <your 32-char token>' \
|
|
283
|
+
-d '{
|
|
284
|
+
"user": "myorg",
|
|
285
|
+
"domain": "myorg.com",
|
|
286
|
+
"slug": "welcome",
|
|
287
|
+
"to": "new.user@myorg.com",
|
|
288
|
+
"variables": {"first_name": "Kai", "cta_url": "https://myorg.com/confirm"}
|
|
289
|
+
}'
|
|
290
|
+
```
|
|
289
291
|
4. Trigger the contact form template the same way your frontend will post to `/v1/form/message` (Supply `form_id` or `idname` of `contact`). With `DB_AUTO_RELOAD=1`, editing the templates or assets is as simple as saving the file.
|
|
290
292
|
|
|
291
293
|
You now have a clean, self-contained configuration for MyOrg that inherits Mail Magic behaviour while keeping templates, partials, and assets under version control in a dedicated folder.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { ApiModule, ApiError } from '@technomoron/api-server-base';
|
|
4
|
+
import { decodeComponent, sendFileAsync } from '../util.js';
|
|
5
|
+
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
6
|
+
const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
7
|
+
export class AssetAPI extends ApiModule {
|
|
8
|
+
async getAsset(apiReq) {
|
|
9
|
+
const domain = decodeComponent(apiReq.req.params.domain);
|
|
10
|
+
if (!domain || !DOMAIN_PATTERN.test(domain)) {
|
|
11
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
12
|
+
}
|
|
13
|
+
const rawPath = apiReq.req.params[0] ?? '';
|
|
14
|
+
const segments = rawPath
|
|
15
|
+
.split('/')
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map((segment) => decodeComponent(segment));
|
|
18
|
+
if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
|
|
19
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
20
|
+
}
|
|
21
|
+
const assetsRoot = path.join(this.server.storage.configpath, domain, 'assets');
|
|
22
|
+
const resolvedRoot = path.resolve(assetsRoot);
|
|
23
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
24
|
+
const candidate = path.resolve(assetsRoot, path.join(...segments));
|
|
25
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
26
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
await fs.promises.access(candidate, fs.constants.R_OK);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
33
|
+
}
|
|
34
|
+
const { res } = apiReq;
|
|
35
|
+
const originalStatus = res.status.bind(res);
|
|
36
|
+
const originalJson = res.json.bind(res);
|
|
37
|
+
res.status = ((code) => (res.headersSent ? res : originalStatus(code)));
|
|
38
|
+
res.json = ((body) => (res.headersSent ? res : originalJson(body)));
|
|
39
|
+
res.type(path.extname(candidate));
|
|
40
|
+
res.set('Cache-Control', 'public, max-age=300');
|
|
41
|
+
try {
|
|
42
|
+
await sendFileAsync(res, candidate);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
this.server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
if (!res.headersSent) {
|
|
47
|
+
throw new ApiError({ code: 500, message: 'Failed to stream asset' });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [200, null];
|
|
51
|
+
}
|
|
52
|
+
defineRoutes() {
|
|
53
|
+
const route = this.server.storage.env.ASSET_ROUTE;
|
|
54
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
method: 'get',
|
|
58
|
+
path: `${normalizedRoute}/:domain/*`,
|
|
59
|
+
handler: (apiReq) => this.getAsset(apiReq),
|
|
60
|
+
auth: { type: 'none', req: 'any' }
|
|
61
|
+
}
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { AssetAPI } from './api/assets.js';
|
|
2
3
|
import { FormAPI } from './api/forms.js';
|
|
3
4
|
import { MailerAPI } from './api/mailer.js';
|
|
4
5
|
import { mailApiServer } from './server.js';
|
|
@@ -10,13 +11,14 @@ function buildServerConfig(store, overrides) {
|
|
|
10
11
|
apiPort: env.API_PORT,
|
|
11
12
|
uploadPath: env.UPLOAD_PATH,
|
|
12
13
|
debug: env.DEBUG,
|
|
14
|
+
apiBasePath: '',
|
|
13
15
|
...overrides
|
|
14
16
|
};
|
|
15
17
|
}
|
|
16
18
|
export async function createMailMagicServer(overrides = {}) {
|
|
17
19
|
const store = await new mailStore().init();
|
|
18
20
|
const config = buildServerConfig(store, overrides);
|
|
19
|
-
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI());
|
|
21
|
+
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
20
22
|
return { server, store, env: store.env };
|
|
21
23
|
}
|
|
22
24
|
export async function startMailMagicServer(overrides = {}) {
|
package/dist/models/init.js
CHANGED
|
@@ -14,34 +14,41 @@ const init_data_schema = z.object({
|
|
|
14
14
|
form: z.array(api_form_schema).default([])
|
|
15
15
|
});
|
|
16
16
|
/**
|
|
17
|
-
* Resolve an asset file within ./config/<
|
|
17
|
+
* Resolve an asset file within ./config/<domain>/assets
|
|
18
18
|
*/
|
|
19
|
-
function resolveAsset(basePath,
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
searchPaths.push(path.join(domainName, type, locale));
|
|
19
|
+
function resolveAsset(basePath, domainName, assetName) {
|
|
20
|
+
const assetsRoot = path.join(basePath, domainName, 'assets');
|
|
21
|
+
if (!fs.existsSync(assetsRoot)) {
|
|
22
|
+
return null;
|
|
24
23
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
24
|
+
const resolvedRoot = path.resolve(assetsRoot);
|
|
25
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
26
|
+
const candidate = path.resolve(assetsRoot, assetName);
|
|
27
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
28
|
+
return null;
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const candidate = path.join(basePath, p, 'assets', assetName);
|
|
34
|
-
if (fs.existsSync(candidate)) {
|
|
35
|
-
return candidate;
|
|
36
|
-
}
|
|
30
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
31
|
+
return candidate;
|
|
37
32
|
}
|
|
38
33
|
return null;
|
|
39
34
|
}
|
|
35
|
+
function buildAssetUrl(baseUrl, route, domainName, assetPath) {
|
|
36
|
+
const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
37
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
38
|
+
const encodedDomain = encodeURIComponent(domainName);
|
|
39
|
+
const encodedPath = assetPath
|
|
40
|
+
.split('/')
|
|
41
|
+
.filter((segment) => segment.length > 0)
|
|
42
|
+
.map((segment) => encodeURIComponent(segment))
|
|
43
|
+
.join('/');
|
|
44
|
+
const trailing = encodedPath ? `/${encodedPath}` : '';
|
|
45
|
+
return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
|
|
46
|
+
}
|
|
40
47
|
function extractAndReplaceAssets(html, opts) {
|
|
41
48
|
const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
|
|
42
49
|
const assets = [];
|
|
43
50
|
const replacedHtml = html.replace(regex, (_m, relPath, inlineFlag) => {
|
|
44
|
-
const fullPath = resolveAsset(opts.basePath, opts.
|
|
51
|
+
const fullPath = resolveAsset(opts.basePath, opts.domainName, relPath);
|
|
45
52
|
if (!fullPath) {
|
|
46
53
|
throw new Error(`Missing asset "${relPath}"`);
|
|
47
54
|
}
|
|
@@ -52,13 +59,17 @@ function extractAndReplaceAssets(html, opts) {
|
|
|
52
59
|
cid: isInline ? relPath : undefined
|
|
53
60
|
};
|
|
54
61
|
assets.push(storedFile);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
if (isInline) {
|
|
63
|
+
return `src="cid:${relPath}"`;
|
|
64
|
+
}
|
|
65
|
+
const domainAssetsRoot = path.join(opts.basePath, opts.domainName, 'assets');
|
|
66
|
+
const relativeToAssets = path.relative(domainAssetsRoot, fullPath);
|
|
67
|
+
if (!relativeToAssets || relativeToAssets.startsWith('..')) {
|
|
68
|
+
throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
|
|
69
|
+
}
|
|
70
|
+
const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
|
|
71
|
+
const assetUrl = buildAssetUrl(opts.apiUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
|
|
72
|
+
return `src="${assetUrl}"`;
|
|
62
73
|
});
|
|
63
74
|
return { html: replacedHtml, assets };
|
|
64
75
|
}
|
|
@@ -90,11 +101,9 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
90
101
|
const merged = processor.flattenNoAssets(templateKey);
|
|
91
102
|
const { html, assets } = extractAndReplaceAssets(merged, {
|
|
92
103
|
basePath: baseConfigPath,
|
|
93
|
-
type,
|
|
94
104
|
domainName: domain.name,
|
|
95
|
-
locale,
|
|
96
105
|
apiUrl: store.env.API_URL,
|
|
97
|
-
|
|
106
|
+
assetRoute: store.env.ASSET_ROUTE
|
|
98
107
|
});
|
|
99
108
|
return { html, assets };
|
|
100
109
|
}
|
package/dist/store/envloader.js
CHANGED
|
@@ -28,31 +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
|
+
ASSET_ROUTE: {
|
|
32
|
+
description: 'Route prefix exposed for config assets',
|
|
33
|
+
default: '/asset'
|
|
34
|
+
},
|
|
31
35
|
CONFIG_PATH: {
|
|
32
36
|
description: 'Path to directory where config files are located',
|
|
33
37
|
default: './config/'
|
|
34
38
|
},
|
|
35
|
-
/*
|
|
36
|
-
SWAGGER_ENABLE: {
|
|
37
|
-
description: 'Enable Swagger API docs',
|
|
38
|
-
default: 'false',
|
|
39
|
-
type: 'boolean'
|
|
40
|
-
},
|
|
41
|
-
SWAGGER_PATH: {
|
|
42
|
-
description: 'Path for swagger api docs',
|
|
43
|
-
default: '/api-docs'
|
|
44
|
-
},
|
|
45
|
-
*/
|
|
46
|
-
/*
|
|
47
|
-
JWT_SECRET: {
|
|
48
|
-
description: 'Secret key for generating JWT access tokens',
|
|
49
|
-
required: true
|
|
50
|
-
},
|
|
51
|
-
JWT_REFRESH: {
|
|
52
|
-
description: 'Secret key for generating JWT refresh tokens',
|
|
53
|
-
required: true
|
|
54
|
-
},
|
|
55
|
-
*/
|
|
56
39
|
DB_USER: {
|
|
57
40
|
description: 'Database username for API database'
|
|
58
41
|
},
|
package/dist/util.js
CHANGED
|
@@ -92,3 +92,26 @@ export function buildRequestMeta(rawReq) {
|
|
|
92
92
|
ip_chain: uniqueIps
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
+
export function decodeComponent(value) {
|
|
96
|
+
if (!value) {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return decodeURIComponent(value);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function sendFileAsync(res, file) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
res.sendFile(file, (err) => {
|
|
109
|
+
if (err) {
|
|
110
|
+
reject(err);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
resolve();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { ApiModule, ApiRoute, ApiError } from '@technomoron/api-server-base';
|
|
5
|
+
|
|
6
|
+
import { mailApiServer } from '../server.js';
|
|
7
|
+
import { decodeComponent, sendFileAsync } from '../util.js';
|
|
8
|
+
|
|
9
|
+
import type { ApiRequest } from '@technomoron/api-server-base';
|
|
10
|
+
|
|
11
|
+
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
12
|
+
const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
13
|
+
|
|
14
|
+
export class AssetAPI extends ApiModule<mailApiServer> {
|
|
15
|
+
private async getAsset(apiReq: ApiRequest): Promise<[number, null]> {
|
|
16
|
+
const domain = decodeComponent(apiReq.req.params.domain);
|
|
17
|
+
if (!domain || !DOMAIN_PATTERN.test(domain)) {
|
|
18
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const rawPath = apiReq.req.params[0] ?? '';
|
|
22
|
+
const segments = rawPath
|
|
23
|
+
.split('/')
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((segment: string) => decodeComponent(segment));
|
|
26
|
+
if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
|
|
27
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const assetsRoot = path.join(this.server.storage.configpath, domain, 'assets');
|
|
31
|
+
const resolvedRoot = path.resolve(assetsRoot);
|
|
32
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
33
|
+
const candidate = path.resolve(assetsRoot, path.join(...segments));
|
|
34
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
35
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await fs.promises.access(candidate, fs.constants.R_OK);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { res } = apiReq;
|
|
45
|
+
const originalStatus = res.status.bind(res);
|
|
46
|
+
const originalJson = res.json.bind(res);
|
|
47
|
+
res.status = ((code: number) => (res.headersSent ? res : originalStatus(code))) as typeof res.status;
|
|
48
|
+
res.json = ((body: unknown) => (res.headersSent ? res : originalJson(body))) as typeof res.json;
|
|
49
|
+
|
|
50
|
+
res.type(path.extname(candidate));
|
|
51
|
+
res.set('Cache-Control', 'public, max-age=300');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await sendFileAsync(res, candidate);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
this.server.storage.print_debug(
|
|
57
|
+
`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`
|
|
58
|
+
);
|
|
59
|
+
if (!res.headersSent) {
|
|
60
|
+
throw new ApiError({ code: 500, message: 'Failed to stream asset' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return [200, null];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
override defineRoutes(): ApiRoute[] {
|
|
68
|
+
const route = this.server.storage.env.ASSET_ROUTE;
|
|
69
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
method: 'get',
|
|
73
|
+
path: `${normalizedRoute}/:domain/*`,
|
|
74
|
+
handler: (apiReq) => this.getAsset(apiReq),
|
|
75
|
+
auth: { type: 'none', req: 'any' }
|
|
76
|
+
}
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
2
|
|
|
3
|
+
import { AssetAPI } from './api/assets.js';
|
|
3
4
|
import { FormAPI } from './api/forms.js';
|
|
4
5
|
import { MailerAPI } from './api/mailer.js';
|
|
5
6
|
import { mailApiServer } from './server.js';
|
|
@@ -22,6 +23,7 @@ function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions):
|
|
|
22
23
|
apiPort: env.API_PORT,
|
|
23
24
|
uploadPath: env.UPLOAD_PATH,
|
|
24
25
|
debug: env.DEBUG,
|
|
26
|
+
apiBasePath: '',
|
|
25
27
|
...overrides
|
|
26
28
|
};
|
|
27
29
|
}
|
|
@@ -29,7 +31,7 @@ function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions):
|
|
|
29
31
|
export async function createMailMagicServer(overrides: MailMagicServerOptions = {}): Promise<MailMagicServerBootstrap> {
|
|
30
32
|
const store = await new mailStore().init();
|
|
31
33
|
const config = buildServerConfig(store, overrides);
|
|
32
|
-
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI());
|
|
34
|
+
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
33
35
|
|
|
34
36
|
return { server, store, env: store.env };
|
|
35
37
|
}
|
package/src/models/init.ts
CHANGED
|
@@ -28,48 +28,45 @@ const init_data_schema = z.object({
|
|
|
28
28
|
type InitData = z.infer<typeof init_data_schema>;
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Resolve an asset file within ./config/<
|
|
31
|
+
* Resolve an asset file within ./config/<domain>/assets
|
|
32
32
|
*/
|
|
33
|
-
function resolveAsset(
|
|
34
|
-
basePath
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
assetName: string,
|
|
38
|
-
locale?: string | null
|
|
39
|
-
): string | null {
|
|
40
|
-
const searchPaths: string[] = [];
|
|
41
|
-
|
|
42
|
-
// always domain-scoped
|
|
43
|
-
if (locale) {
|
|
44
|
-
searchPaths.push(path.join(domainName, type, locale));
|
|
33
|
+
function resolveAsset(basePath: string, domainName: string, assetName: string): string | null {
|
|
34
|
+
const assetsRoot = path.join(basePath, domainName, 'assets');
|
|
35
|
+
if (!fs.existsSync(assetsRoot)) {
|
|
36
|
+
return null;
|
|
45
37
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
searchPaths.push(path.join(type, locale));
|
|
38
|
+
const resolvedRoot = path.resolve(assetsRoot);
|
|
39
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
40
|
+
const candidate = path.resolve(assetsRoot, assetName);
|
|
41
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
42
|
+
return null;
|
|
52
43
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for (const p of searchPaths) {
|
|
56
|
-
const candidate = path.join(basePath, p, 'assets', assetName);
|
|
57
|
-
if (fs.existsSync(candidate)) {
|
|
58
|
-
return candidate;
|
|
59
|
-
}
|
|
44
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
45
|
+
return candidate;
|
|
60
46
|
}
|
|
61
47
|
return null;
|
|
62
48
|
}
|
|
63
49
|
|
|
50
|
+
function buildAssetUrl(baseUrl: string, route: string, domainName: string, assetPath: string): string {
|
|
51
|
+
const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
52
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
53
|
+
const encodedDomain = encodeURIComponent(domainName);
|
|
54
|
+
const encodedPath = assetPath
|
|
55
|
+
.split('/')
|
|
56
|
+
.filter((segment) => segment.length > 0)
|
|
57
|
+
.map((segment) => encodeURIComponent(segment))
|
|
58
|
+
.join('/');
|
|
59
|
+
const trailing = encodedPath ? `/${encodedPath}` : '';
|
|
60
|
+
return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
64
63
|
function extractAndReplaceAssets(
|
|
65
64
|
html: string,
|
|
66
65
|
opts: {
|
|
67
66
|
basePath: string;
|
|
68
|
-
type: 'form-template' | 'tx-template';
|
|
69
67
|
domainName: string;
|
|
70
|
-
locale?: string | null;
|
|
71
68
|
apiUrl: string;
|
|
72
|
-
|
|
69
|
+
assetRoute: string;
|
|
73
70
|
}
|
|
74
71
|
): { html: string; assets: StoredFile[] } {
|
|
75
72
|
const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
|
|
@@ -77,7 +74,7 @@ function extractAndReplaceAssets(
|
|
|
77
74
|
const assets: StoredFile[] = [];
|
|
78
75
|
|
|
79
76
|
const replacedHtml = html.replace(regex, (_m, relPath: string, inlineFlag?: string) => {
|
|
80
|
-
const fullPath = resolveAsset(opts.basePath, opts.
|
|
77
|
+
const fullPath = resolveAsset(opts.basePath, opts.domainName, relPath);
|
|
81
78
|
if (!fullPath) {
|
|
82
79
|
throw new Error(`Missing asset "${relPath}"`);
|
|
83
80
|
}
|
|
@@ -91,13 +88,18 @@ function extractAndReplaceAssets(
|
|
|
91
88
|
|
|
92
89
|
assets.push(storedFile);
|
|
93
90
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
if (isInline) {
|
|
92
|
+
return `src="cid:${relPath}"`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const domainAssetsRoot = path.join(opts.basePath, opts.domainName, 'assets');
|
|
96
|
+
const relativeToAssets = path.relative(domainAssetsRoot, fullPath);
|
|
97
|
+
if (!relativeToAssets || relativeToAssets.startsWith('..')) {
|
|
98
|
+
throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
|
|
99
|
+
}
|
|
100
|
+
const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
|
|
101
|
+
const assetUrl = buildAssetUrl(opts.apiUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
|
|
102
|
+
return `src="${assetUrl}"`;
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
return { html: replacedHtml, assets };
|
|
@@ -146,11 +148,9 @@ async function _load_template(
|
|
|
146
148
|
|
|
147
149
|
const { html, assets } = extractAndReplaceAssets(merged, {
|
|
148
150
|
basePath: baseConfigPath,
|
|
149
|
-
type,
|
|
150
151
|
domainName: domain.name,
|
|
151
|
-
locale,
|
|
152
152
|
apiUrl: store.env.API_URL,
|
|
153
|
-
|
|
153
|
+
assetRoute: store.env.ASSET_ROUTE
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
return { html, assets };
|
package/src/store/envloader.ts
CHANGED
|
@@ -29,31 +29,14 @@ export const envOptions = defineEnvOptions({
|
|
|
29
29
|
description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
|
|
30
30
|
default: 'http://localhost:3776'
|
|
31
31
|
},
|
|
32
|
+
ASSET_ROUTE: {
|
|
33
|
+
description: 'Route prefix exposed for config assets',
|
|
34
|
+
default: '/asset'
|
|
35
|
+
},
|
|
32
36
|
CONFIG_PATH: {
|
|
33
37
|
description: 'Path to directory where config files are located',
|
|
34
38
|
default: './config/'
|
|
35
39
|
},
|
|
36
|
-
/*
|
|
37
|
-
SWAGGER_ENABLE: {
|
|
38
|
-
description: 'Enable Swagger API docs',
|
|
39
|
-
default: 'false',
|
|
40
|
-
type: 'boolean'
|
|
41
|
-
},
|
|
42
|
-
SWAGGER_PATH: {
|
|
43
|
-
description: 'Path for swagger api docs',
|
|
44
|
-
default: '/api-docs'
|
|
45
|
-
},
|
|
46
|
-
*/
|
|
47
|
-
/*
|
|
48
|
-
JWT_SECRET: {
|
|
49
|
-
description: 'Secret key for generating JWT access tokens',
|
|
50
|
-
required: true
|
|
51
|
-
},
|
|
52
|
-
JWT_REFRESH: {
|
|
53
|
-
description: 'Secret key for generating JWT refresh tokens',
|
|
54
|
-
required: true
|
|
55
|
-
},
|
|
56
|
-
*/
|
|
57
40
|
DB_USER: {
|
|
58
41
|
description: 'Database username for API database'
|
|
59
42
|
},
|
package/src/util.ts
CHANGED
|
@@ -109,3 +109,29 @@ export function buildRequestMeta(rawReq: unknown): RequestMeta {
|
|
|
109
109
|
ip_chain: uniqueIps
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
export function decodeComponent(value: string | undefined): string {
|
|
114
|
+
if (!value) {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return decodeURIComponent(value);
|
|
119
|
+
} catch {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function sendFileAsync(
|
|
125
|
+
res: { sendFile: (path: string, cb: (err?: Error | null) => void) => void },
|
|
126
|
+
file: string
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
res.sendFile(file, (err) => {
|
|
130
|
+
if (err) {
|
|
131
|
+
reject(err);
|
|
132
|
+
} else {
|
|
133
|
+
resolve();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|