@technomoron/mail-magic 1.0.43 → 2.0.0-beta1
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 +39 -0
- package/README.md +25 -9
- package/dist/esm/api/mailer.js +5 -1
- package/dist/esm/api/reload.d.ts +7 -0
- package/dist/esm/api/reload.js +30 -0
- package/dist/esm/bin/mail-magic.js +0 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +18 -29
- package/dist/esm/models/init.d.ts +3 -1
- package/dist/esm/models/init.js +5 -5
- package/dist/esm/store/envloader.d.ts +6 -13
- package/dist/esm/store/envloader.js +9 -16
- package/dist/esm/store/store.d.ts +11 -2
- package/dist/esm/store/store.js +98 -25
- package/dist/esm/swagger.d.ts +0 -3
- package/dist/esm/swagger.js +4 -40
- package/dist/esm/util/route.d.ts +3 -0
- package/dist/esm/util/route.js +3 -0
- package/dist/esm/util/utils.d.ts +7 -0
- package/dist/esm/util/utils.js +7 -0
- package/docs/swagger/openapi.json +77 -5
- package/docs/tutorial.md +5 -4
- package/examples/.env-dist +1 -3
- package/package.json +93 -90
package/CHANGES
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
CHANGES
|
|
2
2
|
=======
|
|
3
3
|
|
|
4
|
+
Unreleased (2026-03-07)
|
|
5
|
+
|
|
6
|
+
- chore(release): add package-local `release:preflight` support so a single package can run cleanbuild, tests, and local release checks from its own directory.
|
|
7
|
+
- chore(release): stop using `setup-node` pnpm caching in the GitHub server release workflow because this repo does not ship a `pnpm-lock.yaml`.
|
|
8
|
+
- chore(release): install workspace dependencies with `pnpm install --no-frozen-lockfile --link-workspace-packages` in the GitHub server release workflow because this repo does not ship a `pnpm-lock.yaml` and the CLI depends on the local prerelease client package.
|
|
9
|
+
- chore(release): add a repo-level pnpm build-script allowlist for `sqlite3` and `esbuild`, while explicitly blocking `@scarf/scarf`, so clean GitHub installs can run the server test/build pipeline.
|
|
10
|
+
- (Changes generated/assisted by Codex (profile: openai-gpt-5-codex/medium).)
|
|
11
|
+
|
|
12
|
+
Version 2.0.0-beta1 (2026-03-07)
|
|
13
|
+
|
|
14
|
+
- chore(release): stage the `2.0.0-beta1` prerelease and align package metadata with it.
|
|
15
|
+
- chore(release): add shared local/CI release verification flow and update the GitHub server release workflow to use it on Node 24.
|
|
16
|
+
- chore(release): publish tagged GitHub server releases to npm via a shared `npm publish` flow and add a local publish dry-run path for pre-tag testing.
|
|
17
|
+
- docs(readme): point npm users to the packaged `examples/.env-dist` and example config tree instead of the monorepo root.
|
|
18
|
+
- docs(swagger): refresh the packaged OpenAPI spec to `2.0.0-beta1` and document `POST /api/v1/reload`.
|
|
19
|
+
- (Changes generated/assisted by Codex (profile: openai-gpt-5-codex/medium).)
|
|
20
|
+
|
|
21
|
+
Version 1.0.45 (2026-03-06)
|
|
22
|
+
|
|
23
|
+
- feat(reload): add `mailStore.triggerReload(force?)` with in-progress guard — concurrent reload requests are coalesced into a single queued follow-up run rather than running in parallel.
|
|
24
|
+
- feat(reload): add `POST /v1/reload` API endpoint that triggers a force-reload of all templates; returns `{ reload: 'triggered' | 'queued' }`.
|
|
25
|
+
- fix(routes): make the server route contract fixed again (`/api`, `/asset`, `/api/swagger`) instead of env-configurable.
|
|
26
|
+
- docs(routes): remove `API_BASE_PATH`, `ASSET_ROUTE`, and `SWAGGER_PATH` from server env/docs/examples; document the fixed public routes.
|
|
27
|
+
- test(routes): update helpers and integration fixtures to use the fixed route contract; guard root integration teardown when setup fails.
|
|
28
|
+
- fix(mailer): clear stale transactional template asset metadata when `POST /api/v1/tx/template` updates an existing template.
|
|
29
|
+
- test(mailer): add regression coverage for transactional template API updates and stronger public form recipient contract assertions.
|
|
30
|
+
- feat(autoreload): watch `*.njk` files under `CONFIG_PATH` with chokidar; force-reload all templates (including already-loaded ones) when any template file changes.
|
|
31
|
+
- fix(autoreload): `importData` now accepts `{ force }` option to re-read templates from disk even when a DB record already has rendered content.
|
|
32
|
+
- docs(tutorial): correct `DB_AUTO_RELOAD` description — init-data.json triggers metadata re-import; `.njk` changes trigger template force-reload.
|
|
33
|
+
- (Changes generated/assisted by Codex (profile: openai-gpt-5-codex/medium).)
|
|
34
|
+
- (Changes generated/assisted by Claude Code (profile: anthropic-claude-opus-4-6/high).)
|
|
35
|
+
|
|
36
|
+
Version 1.0.44 (2026-03-05)
|
|
37
|
+
|
|
38
|
+
- fix(autoreload): catch unhandled promise rejections from async `reload()` in `enableInitDataAutoReload` `onChange` callback.
|
|
39
|
+
- fix(mailer): validate that parsed `vars` is a non-null, non-array object after `JSON.parse`; return 400 for invalid types.
|
|
40
|
+
- docs(utils): add doc comment to `buildRequestMeta` clarifying it is informational and requires a trusted reverse proxy for reliable IP data.
|
|
41
|
+
- (Changes generated/assisted by Claude Code (profile: anthropic-claude-opus-4-6/high).)
|
|
42
|
+
|
|
4
43
|
Version 1.0.43 (2026-03-04)
|
|
5
44
|
|
|
6
45
|
- fix(security): add allowlist for custom email headers on `/v1/tx/message` to prevent header injection via arbitrary keys (e.g. `Bcc`, `From`, `Sender`).
|
package/README.md
CHANGED
|
@@ -38,13 +38,25 @@ endpoint, use the OpenAPI spec described in **Swagger / OpenAPI** below.
|
|
|
38
38
|
npm install @technomoron/mail-magic
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
The package ships a `mail-magic` CLI that loads a `.env` file and starts the server.
|
|
41
|
+
The package ships a `mail-magic` CLI that loads a `.env` file and starts the server. It also ships a runnable example
|
|
42
|
+
env/config tree under `examples/`.
|
|
42
43
|
|
|
43
44
|
## Quick Start
|
|
44
45
|
|
|
45
46
|
### 1. Create a `.env`
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
If you installed from npm, start from:
|
|
49
|
+
|
|
50
|
+
`node_modules/@technomoron/mail-magic/examples/.env-dist`
|
|
51
|
+
|
|
52
|
+
If you are working from the monorepo, the same file lives at:
|
|
53
|
+
|
|
54
|
+
`packages/server/examples/.env-dist`
|
|
55
|
+
|
|
56
|
+
That example env is tuned for local development. For internet-facing form deployments, review the security notes and set
|
|
57
|
+
stricter values for rate limiting, CAPTCHA, attachment limits, schema migration, and SMTP TLS verification.
|
|
58
|
+
|
|
59
|
+
Set at least:
|
|
48
60
|
|
|
49
61
|
```ini
|
|
50
62
|
# REQUIRED. Keep stable: used to HMAC API tokens before DB lookup.
|
|
@@ -54,7 +66,6 @@ CONFIG_PATH=./data
|
|
|
54
66
|
|
|
55
67
|
API_HOST=127.0.0.1
|
|
56
68
|
API_PORT=3776
|
|
57
|
-
API_BASE_PATH=/api
|
|
58
69
|
API_URL=http://127.0.0.1:3776
|
|
59
70
|
|
|
60
71
|
SMTP_HOST=127.0.0.1
|
|
@@ -66,6 +77,14 @@ SMTP_TLS_REJECT=false
|
|
|
66
77
|
|
|
67
78
|
### 2. Create a minimal config directory
|
|
68
79
|
|
|
80
|
+
If you want a ready-made starting point instead of creating one from scratch, copy:
|
|
81
|
+
|
|
82
|
+
- `node_modules/@technomoron/mail-magic/examples/data`
|
|
83
|
+
|
|
84
|
+
or, from the monorepo:
|
|
85
|
+
|
|
86
|
+
- `packages/server/examples/data`
|
|
87
|
+
|
|
69
88
|
`CONFIG_PATH` points at a directory containing `init-data.json` plus per-domain subfolders:
|
|
70
89
|
|
|
71
90
|
```text
|
|
@@ -135,9 +154,7 @@ The full set of environment variables is documented in the repository’s `.env-
|
|
|
135
154
|
Commonly used variables:
|
|
136
155
|
|
|
137
156
|
- `API_HOST`, `API_PORT`, `API_URL`
|
|
138
|
-
- `API_BASE_PATH` (default `/api`)
|
|
139
157
|
- `CONFIG_PATH` (default `./data/`)
|
|
140
|
-
- `ASSET_ROUTE` (default `/asset`)
|
|
141
158
|
- `ASSET_PUBLIC_BASE` (optional public base URL for assets)
|
|
142
159
|
- `AUTOESCAPE_HTML` (default `true`)
|
|
143
160
|
- `UPLOAD_PATH`, `UPLOAD_MAX` (multipart uploads)
|
|
@@ -150,7 +167,7 @@ Commonly used variables:
|
|
|
150
167
|
- `SMTP_REQUIRE_TLS` (default `true`; set to `false` for local dev servers like MailHog that don't support STARTTLS)
|
|
151
168
|
- `SMTP_TLS_REJECT` (default `true`; set to `false` to accept self-signed certificates)
|
|
152
169
|
- Swagger/OpenAPI:
|
|
153
|
-
- `SWAGGER_ENABLED
|
|
170
|
+
- `SWAGGER_ENABLED` (serves `/api/swagger`)
|
|
154
171
|
|
|
155
172
|
## Swagger / OpenAPI
|
|
156
173
|
|
|
@@ -163,8 +180,7 @@ Packaged spec (on disk):
|
|
|
163
180
|
Runtime spec endpoint:
|
|
164
181
|
|
|
165
182
|
- set `SWAGGER_ENABLED=true`
|
|
166
|
-
-
|
|
167
|
-
- fetch the JSON from that endpoint and feed it to Swagger UI / Postman / Insomnia
|
|
183
|
+
- fetch `/api/swagger` and feed the JSON to Swagger UI / Postman / Insomnia
|
|
168
184
|
|
|
169
185
|
This spec is the canonical reference for:
|
|
170
186
|
|
|
@@ -280,7 +296,7 @@ by the form template’s `allowed_fields` setting).
|
|
|
280
296
|
|
|
281
297
|
Templates may reference assets with `asset('path')`:
|
|
282
298
|
|
|
283
|
-
- `asset('images/logo.png')` rewrites to a public URL under
|
|
299
|
+
- `asset('images/logo.png')` rewrites to a public URL under `/asset`
|
|
284
300
|
- `asset('images/logo.png', true)` embeds as a CID attachment
|
|
285
301
|
|
|
286
302
|
All assets must live under:
|
package/dist/esm/api/mailer.js
CHANGED
|
@@ -50,7 +50,8 @@ export class MailerAPI extends ApiModule {
|
|
|
50
50
|
subject,
|
|
51
51
|
locale,
|
|
52
52
|
sender,
|
|
53
|
-
template
|
|
53
|
+
template,
|
|
54
|
+
files: []
|
|
54
55
|
};
|
|
55
56
|
try {
|
|
56
57
|
const [templateRecord, created] = await api_txmail.upsert(data, {
|
|
@@ -89,6 +90,9 @@ export class MailerAPI extends ApiModule {
|
|
|
89
90
|
throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
|
|
90
91
|
}
|
|
91
92
|
}
|
|
93
|
+
if (!parsedVars || typeof parsedVars !== 'object' || Array.isArray(parsedVars)) {
|
|
94
|
+
throw new ApiError({ code: 400, message: '"vars" must be a JSON object' });
|
|
95
|
+
}
|
|
92
96
|
const thevars = parsedVars;
|
|
93
97
|
const { valid, invalid } = this.validateEmails(rcpt);
|
|
94
98
|
if (invalid.length > 0) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ApiError, ApiModule } from '@technomoron/api-server-base';
|
|
2
|
+
import { api_user } from '../models/user.js';
|
|
3
|
+
export class ReloadAPI extends ApiModule {
|
|
4
|
+
async assertUser(apireq) {
|
|
5
|
+
const rawUid = apireq.getRealUid();
|
|
6
|
+
const uid = rawUid === null ? null : Number(rawUid);
|
|
7
|
+
if (!uid || Number.isNaN(uid)) {
|
|
8
|
+
throw new ApiError({ code: 401, message: 'Invalid/Unknown API Key/Token' });
|
|
9
|
+
}
|
|
10
|
+
const user = await api_user.findByPk(uid);
|
|
11
|
+
if (!user) {
|
|
12
|
+
throw new ApiError({ code: 401, message: 'Invalid/Unknown API Key/Token' });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async postReload(apireq) {
|
|
16
|
+
await this.assertUser(apireq);
|
|
17
|
+
const reload = this.server.storage.triggerReload(true);
|
|
18
|
+
return [200, { Status: 'OK', reload }];
|
|
19
|
+
}
|
|
20
|
+
defineRoutes() {
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
method: 'post',
|
|
24
|
+
path: '/v1/reload',
|
|
25
|
+
auth: { type: 'yes', req: 'any' },
|
|
26
|
+
handler: (req) => this.postReload(req)
|
|
27
|
+
}
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
File without changes
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mailApiServer } from './server.js';
|
|
2
2
|
import { MailStoreVars, mailStore } from './store/store.js';
|
|
3
3
|
import type { ApiServerConf } from '@technomoron/api-server-base';
|
|
4
|
-
export type MailMagicServerOptions = Partial<ApiServerConf
|
|
4
|
+
export type MailMagicServerOptions = Partial<Omit<ApiServerConf, 'apiBasePath' | 'swaggerPath'>>;
|
|
5
5
|
export type MailMagicServerBootstrap = {
|
|
6
6
|
server: mailApiServer;
|
|
7
7
|
store: mailStore;
|
package/dist/esm/index.js
CHANGED
|
@@ -2,10 +2,11 @@ import { pathToFileURL } from 'node:url';
|
|
|
2
2
|
import { AssetAPI, createAssetHandler } from './api/assets.js';
|
|
3
3
|
import { FormAPI } from './api/forms.js';
|
|
4
4
|
import { MailerAPI } from './api/mailer.js';
|
|
5
|
+
import { ReloadAPI } from './api/reload.js';
|
|
5
6
|
import { mailApiServer } from './server.js';
|
|
6
7
|
import { mailStore } from './store/store.js';
|
|
7
8
|
import { installMailMagicSwagger } from './swagger.js';
|
|
8
|
-
import {
|
|
9
|
+
import { MAIL_MAGIC_API_BASE_PATH, MAIL_MAGIC_ASSET_ROUTE, MAIL_MAGIC_SWAGGER_PATH } from './util/route.js';
|
|
9
10
|
export const STARTUP_ERROR_MESSAGE = 'Failed to start mail-magic:';
|
|
10
11
|
function mergeStaticDirs(base, override) {
|
|
11
12
|
const merged = { ...base, ...(override ?? {}) };
|
|
@@ -22,19 +23,16 @@ function buildServerConfig(store, overrides) {
|
|
|
22
23
|
uploadPath: store.getUploadStagingPath(),
|
|
23
24
|
uploadMax: env.UPLOAD_MAX,
|
|
24
25
|
debug: env.DEBUG,
|
|
25
|
-
apiBasePath: normalizeRoute(env.API_BASE_PATH, '/api'),
|
|
26
26
|
swaggerEnabled: env.SWAGGER_ENABLED,
|
|
27
|
-
swaggerPath: env.SWAGGER_PATH,
|
|
28
27
|
apiKeyEnabled: true,
|
|
29
28
|
apiKeyPrefix: 'apikey-',
|
|
30
|
-
...overrides
|
|
29
|
+
...overrides,
|
|
30
|
+
apiBasePath: MAIL_MAGIC_API_BASE_PATH,
|
|
31
|
+
swaggerPath: MAIL_MAGIC_SWAGGER_PATH
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
export async function createMailMagicServer(overrides = {}, envOverrides = {}) {
|
|
34
35
|
const store = await new mailStore().init(envOverrides);
|
|
35
|
-
if (typeof overrides.apiBasePath === 'string') {
|
|
36
|
-
store.vars.API_BASE_PATH = overrides.apiBasePath;
|
|
37
|
-
}
|
|
38
36
|
const baseStaticDirs = {};
|
|
39
37
|
let adminUiPath = null;
|
|
40
38
|
if (store.vars.ADMIN_ENABLED) {
|
|
@@ -48,32 +46,23 @@ export async function createMailMagicServer(overrides = {}, envOverrides = {}) {
|
|
|
48
46
|
staticDirs: mergeStaticDirs(baseStaticDirs, overrides.staticDirs)
|
|
49
47
|
};
|
|
50
48
|
const config = buildServerConfig(store, mergedOverrides);
|
|
51
|
-
// ApiServerBase's built-in swagger handler loads from process.cwd(); install our own handler so
|
|
49
|
+
// ApiServerBase's built-in swagger handler loads from process.cwd(); install our own fixed-path handler so
|
|
52
50
|
// SWAGGER_ENABLED works regardless of where the .env lives (mail-magic CLI chdir's to the env dir).
|
|
53
|
-
const { swaggerEnabled
|
|
51
|
+
const { swaggerEnabled } = config;
|
|
54
52
|
const serverConfig = { ...config, swaggerEnabled: false, swaggerPath: '' };
|
|
55
|
-
const server = new mailApiServer(serverConfig, store)
|
|
53
|
+
const server = new mailApiServer(serverConfig, store)
|
|
54
|
+
.api(new MailerAPI())
|
|
55
|
+
.api(new FormAPI())
|
|
56
|
+
.api(new AssetAPI())
|
|
57
|
+
.api(new ReloadAPI());
|
|
56
58
|
installMailMagicSwagger(server, {
|
|
57
|
-
apiBasePath: String(config.apiBasePath || '/api'),
|
|
58
|
-
assetRoute: String(store.vars.ASSET_ROUTE || '/asset'),
|
|
59
59
|
apiUrl: String(store.vars.API_URL || ''),
|
|
60
|
-
swaggerEnabled
|
|
61
|
-
swaggerPath
|
|
60
|
+
swaggerEnabled
|
|
62
61
|
});
|
|
63
|
-
// Serve domain assets from
|
|
64
|
-
const assetRoute = normalizeRoute(store.vars.ASSET_ROUTE, '/asset');
|
|
65
|
-
const assetPrefix = assetRoute === '/' ? '' : assetRoute;
|
|
66
|
-
const apiBasePath = normalizeRoute(store.vars.API_BASE_PATH, '/api');
|
|
67
|
-
const apiBasePrefix = apiBasePath === '/' ? '' : apiBasePath;
|
|
62
|
+
// Serve domain assets from the fixed public route with traversal protection and caching.
|
|
68
63
|
const assetHandler = createAssetHandler(server);
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
// Integration tests (and API_URL defaults) expect assets to also be reachable under the API base path.
|
|
72
|
-
if (apiBasePrefix && assetPrefix && !assetPrefix.startsWith(`${apiBasePrefix}/`)) {
|
|
73
|
-
assetMounts.add(`${apiBasePrefix}${assetPrefix}`);
|
|
74
|
-
}
|
|
75
|
-
for (const prefix of assetMounts) {
|
|
76
|
-
// Use ApiServer.useExpress() so mounts under `apiBasePath` are installed before the API
|
|
64
|
+
for (const prefix of [MAIL_MAGIC_ASSET_ROUTE, `${MAIL_MAGIC_API_BASE_PATH}${MAIL_MAGIC_ASSET_ROUTE}`]) {
|
|
65
|
+
// Use ApiServer.useExpress() so mounts under the fixed API path are installed before the API
|
|
77
66
|
// 404 handler. Fastify (find-my-way) requires the wildcard to be an unnamed `*`.
|
|
78
67
|
server.useExpress(`${prefix}/:domain/*`, assetHandler);
|
|
79
68
|
}
|
|
@@ -131,8 +120,8 @@ async function enableAdminFeatures(server, store, adminUiPath) {
|
|
|
131
120
|
const mod = (await import('@technomoron/mail-magic-admin'));
|
|
132
121
|
if (typeof mod?.registerAdmin === 'function') {
|
|
133
122
|
await mod.registerAdmin(server, {
|
|
134
|
-
apiBasePath:
|
|
135
|
-
assetRoute:
|
|
123
|
+
apiBasePath: MAIL_MAGIC_API_BASE_PATH,
|
|
124
|
+
assetRoute: MAIL_MAGIC_ASSET_ROUTE,
|
|
136
125
|
appPath: adminUiPath ?? store.vars.ADMIN_APP_PATH,
|
|
137
126
|
logger: (message) => store.print_debug(message),
|
|
138
127
|
staticFallback: Boolean(adminUiPath)
|
|
@@ -8,5 +8,7 @@ interface LoadedTemplate {
|
|
|
8
8
|
}
|
|
9
9
|
export declare function loadFormTemplate(store: mailStore, form: api_form_type): Promise<LoadedTemplate>;
|
|
10
10
|
export declare function loadTxTemplate(store: mailStore, template: api_txmail_type): Promise<LoadedTemplate>;
|
|
11
|
-
export declare function importData(store: mailStore
|
|
11
|
+
export declare function importData(store: mailStore, options?: {
|
|
12
|
+
force?: boolean;
|
|
13
|
+
}): Promise<void>;
|
|
12
14
|
export {};
|
package/dist/esm/models/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { buildAssetUrl } from '../util/paths.js';
|
|
5
|
+
import { MAIL_MAGIC_ASSET_ROUTE } from '../util/route.js';
|
|
5
6
|
import { flattenTemplateWithAssets } from '../util/shared-template-flatten.js';
|
|
6
7
|
import { user_and_domain } from '../util.js';
|
|
7
8
|
import { api_domain, api_domain_schema } from './domain.js';
|
|
@@ -51,12 +52,11 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
51
52
|
throw new Error(`Unable to resolve template path for "${absPath}"`);
|
|
52
53
|
}
|
|
53
54
|
const assetBaseUrl = store.vars.ASSET_PUBLIC_BASE?.trim() ? store.vars.ASSET_PUBLIC_BASE : store.vars.API_URL;
|
|
54
|
-
const assetRoute = store.vars.ASSET_ROUTE;
|
|
55
55
|
const { html, assets } = flattenTemplateWithAssets({
|
|
56
56
|
domainRoot,
|
|
57
57
|
templateKey,
|
|
58
58
|
baseUrl: assetBaseUrl,
|
|
59
|
-
assetFormatter: (urlPath) => buildAssetUrl(assetBaseUrl,
|
|
59
|
+
assetFormatter: (urlPath) => buildAssetUrl(assetBaseUrl, MAIL_MAGIC_ASSET_ROUTE, domain.name, urlPath),
|
|
60
60
|
normalizeInlineCid: buildInlineAssetCid
|
|
61
61
|
});
|
|
62
62
|
return { html, assets: assets };
|
|
@@ -75,7 +75,7 @@ export async function loadTxTemplate(store, template) {
|
|
|
75
75
|
const locale = template.locale || domain.locale || null;
|
|
76
76
|
return _load_template(store, template.filename, '', user, domain, locale, 'tx-template');
|
|
77
77
|
}
|
|
78
|
-
export async function importData(store) {
|
|
78
|
+
export async function importData(store, options) {
|
|
79
79
|
const initfile = path.join(store.configpath, 'init-data.json');
|
|
80
80
|
if (fs.existsSync(initfile)) {
|
|
81
81
|
store.print_debug(`Loading init data from ${initfile}`);
|
|
@@ -125,7 +125,7 @@ export async function importData(store) {
|
|
|
125
125
|
store.print_debug('Creating template records');
|
|
126
126
|
for (const record of records.template) {
|
|
127
127
|
const fixed = await upsert_txmail(record);
|
|
128
|
-
if (!fixed.template) {
|
|
128
|
+
if (!fixed.template || options?.force) {
|
|
129
129
|
const { html, assets } = await loadTxTemplate(store, fixed);
|
|
130
130
|
await fixed.update({ template: html, files: assets });
|
|
131
131
|
}
|
|
@@ -135,7 +135,7 @@ export async function importData(store) {
|
|
|
135
135
|
store.print_debug('Creating form records');
|
|
136
136
|
for (const record of records.form) {
|
|
137
137
|
const fixed = await upsert_form(record);
|
|
138
|
-
if (!fixed.template) {
|
|
138
|
+
if (!fixed.template || options?.force) {
|
|
139
139
|
const { html, assets } = await loadFormTemplate(store, fixed);
|
|
140
140
|
await fixed.update({ template: html, files: assets });
|
|
141
141
|
}
|
|
@@ -18,6 +18,11 @@ export declare const envOptions: {
|
|
|
18
18
|
type: "boolean";
|
|
19
19
|
default: false;
|
|
20
20
|
};
|
|
21
|
+
DB_RELOAD_DEBOUNCE_MS: {
|
|
22
|
+
description: string;
|
|
23
|
+
type: "number";
|
|
24
|
+
default: number;
|
|
25
|
+
};
|
|
21
26
|
DB_FORCE_SYNC: {
|
|
22
27
|
description: string;
|
|
23
28
|
type: "boolean";
|
|
@@ -32,22 +37,14 @@ export declare const envOptions: {
|
|
|
32
37
|
description: string;
|
|
33
38
|
default: string;
|
|
34
39
|
};
|
|
35
|
-
API_BASE_PATH: {
|
|
36
|
-
description: string;
|
|
37
|
-
default: string;
|
|
38
|
-
};
|
|
39
40
|
ASSET_PUBLIC_BASE: {
|
|
40
41
|
description: string;
|
|
41
42
|
default: string;
|
|
42
43
|
};
|
|
43
44
|
SWAGGER_ENABLED: {
|
|
44
45
|
description: string;
|
|
45
|
-
type: "boolean";
|
|
46
46
|
default: false;
|
|
47
|
-
|
|
48
|
-
SWAGGER_PATH: {
|
|
49
|
-
description: string;
|
|
50
|
-
default: string;
|
|
47
|
+
type: "boolean";
|
|
51
48
|
};
|
|
52
49
|
ADMIN_ENABLED: {
|
|
53
50
|
description: string;
|
|
@@ -58,10 +55,6 @@ export declare const envOptions: {
|
|
|
58
55
|
description: string;
|
|
59
56
|
default: string;
|
|
60
57
|
};
|
|
61
|
-
ASSET_ROUTE: {
|
|
62
|
-
description: string;
|
|
63
|
-
default: string;
|
|
64
|
-
};
|
|
65
58
|
CONFIG_PATH: {
|
|
66
59
|
description: string;
|
|
67
60
|
default: string;
|
|
@@ -15,10 +15,15 @@ export const envOptions = defineEnvOptions({
|
|
|
15
15
|
default: '0.0.0.0'
|
|
16
16
|
},
|
|
17
17
|
DB_AUTO_RELOAD: {
|
|
18
|
-
description: '
|
|
18
|
+
description: 'Watch init-data.json and *.njk template files for changes and reload automatically',
|
|
19
19
|
type: 'boolean',
|
|
20
20
|
default: false
|
|
21
21
|
},
|
|
22
|
+
DB_RELOAD_DEBOUNCE_MS: {
|
|
23
|
+
description: 'Debounce delay in milliseconds before triggering a reload after a file change (default 300)',
|
|
24
|
+
type: 'number',
|
|
25
|
+
default: 300
|
|
26
|
+
},
|
|
22
27
|
DB_FORCE_SYNC: {
|
|
23
28
|
description: 'Drop and recreate database tables on startup (DANGEROUS)',
|
|
24
29
|
type: 'boolean',
|
|
@@ -33,22 +38,14 @@ export const envOptions = defineEnvOptions({
|
|
|
33
38
|
description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
|
|
34
39
|
default: 'http://localhost:3776'
|
|
35
40
|
},
|
|
36
|
-
API_BASE_PATH: {
|
|
37
|
-
description: 'Base path prefix for API routes',
|
|
38
|
-
default: '/api'
|
|
39
|
-
},
|
|
40
41
|
ASSET_PUBLIC_BASE: {
|
|
41
42
|
description: 'Public base URL for asset hosting (origin or origin + path)',
|
|
42
43
|
default: ''
|
|
43
44
|
},
|
|
44
45
|
SWAGGER_ENABLED: {
|
|
45
|
-
description: 'Enable the Swagger/OpenAPI endpoint',
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
},
|
|
49
|
-
SWAGGER_PATH: {
|
|
50
|
-
description: 'Path to expose the Swagger/OpenAPI spec (default: /api/swagger when enabled)',
|
|
51
|
-
default: ''
|
|
46
|
+
description: 'Enable the Swagger/OpenAPI endpoint at /api/swagger',
|
|
47
|
+
default: false,
|
|
48
|
+
type: 'boolean'
|
|
52
49
|
},
|
|
53
50
|
ADMIN_ENABLED: {
|
|
54
51
|
description: 'Enable the optional admin UI and admin API module when available',
|
|
@@ -59,10 +56,6 @@ export const envOptions = defineEnvOptions({
|
|
|
59
56
|
description: 'Optional path to the admin UI dist directory (or its parent)',
|
|
60
57
|
default: ''
|
|
61
58
|
},
|
|
62
|
-
ASSET_ROUTE: {
|
|
63
|
-
description: 'Route prefix exposed for config assets',
|
|
64
|
-
default: '/asset'
|
|
65
|
-
},
|
|
66
59
|
CONFIG_PATH: {
|
|
67
60
|
description: 'Path to directory where config files are located',
|
|
68
61
|
default: './data/'
|
|
@@ -14,11 +14,11 @@ type AutoReloadHandle = {
|
|
|
14
14
|
close: () => void;
|
|
15
15
|
};
|
|
16
16
|
type AutoReloadContext = {
|
|
17
|
-
vars: Pick<MailStoreVars, 'DB_AUTO_RELOAD'>;
|
|
17
|
+
vars: Pick<MailStoreVars, 'DB_AUTO_RELOAD' | 'DB_RELOAD_DEBOUNCE_MS'>;
|
|
18
18
|
config_filename: (name: string) => string;
|
|
19
19
|
print_debug: (msg: string) => void;
|
|
20
20
|
};
|
|
21
|
-
export declare function enableInitDataAutoReload(ctx: AutoReloadContext, reload: () => void): AutoReloadHandle | null;
|
|
21
|
+
export declare function enableInitDataAutoReload(ctx: AutoReloadContext, reload: () => void | Promise<void>, reloadForce?: () => void | Promise<void>): AutoReloadHandle | null;
|
|
22
22
|
export declare class mailStore {
|
|
23
23
|
private env;
|
|
24
24
|
vars: MailStoreVars;
|
|
@@ -28,8 +28,17 @@ export declare class mailStore {
|
|
|
28
28
|
uploadTemplate?: string;
|
|
29
29
|
uploadStagingPath?: string;
|
|
30
30
|
autoReloadHandle: AutoReloadHandle | null;
|
|
31
|
+
private reloadInProgress;
|
|
32
|
+
private reloadQueued;
|
|
33
|
+
private reloadQueuedForce;
|
|
31
34
|
print_debug(msg: string): void;
|
|
32
35
|
config_filename(name: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Trigger an importData reload. If a reload is already in progress the request is
|
|
38
|
+
* queued (at most one pending run) so no reload is silently dropped. Returns
|
|
39
|
+
* 'triggered' when a new run starts, or 'queued' when one is already running.
|
|
40
|
+
*/
|
|
41
|
+
triggerReload(force?: boolean): 'triggered' | 'queued';
|
|
33
42
|
resolveUploadPath(domainName?: string): string;
|
|
34
43
|
getUploadStagingPath(): string;
|
|
35
44
|
relocateUploads(domainName: string | null, files: UploadedFile[]): Promise<void>;
|
package/dist/esm/store/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { EnvLoader } from '@technomoron/env-loader';
|
|
4
|
+
import { watch as chokidarWatch } from 'chokidar';
|
|
4
5
|
import { createTransport } from 'nodemailer';
|
|
5
6
|
import { connect_api_db } from '../models/db.js';
|
|
6
7
|
import { importData } from '../models/init.js';
|
|
@@ -24,41 +25,77 @@ function create_mail_transport(vars) {
|
|
|
24
25
|
}
|
|
25
26
|
return createTransport(args);
|
|
26
27
|
}
|
|
27
|
-
export function enableInitDataAutoReload(ctx, reload) {
|
|
28
|
+
export function enableInitDataAutoReload(ctx, reload, reloadForce) {
|
|
28
29
|
if (!ctx.vars.DB_AUTO_RELOAD) {
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
31
32
|
const initDataPath = ctx.config_filename('init-data.json');
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
33
|
+
const configPath = path.dirname(initDataPath);
|
|
34
|
+
const debounceMs = ctx.vars.DB_RELOAD_DEBOUNCE_MS ?? 300;
|
|
35
|
+
function makeDebounced(fn, label) {
|
|
36
|
+
let timer = null;
|
|
37
|
+
const trigger = () => {
|
|
38
|
+
if (timer)
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
timer = setTimeout(() => {
|
|
41
|
+
timer = null;
|
|
42
|
+
ctx.print_debug(label);
|
|
43
|
+
// fn() may be sync or async — try/catch handles a synchronous
|
|
44
|
+
// throw, while Promise.resolve().catch() handles an async rejection.
|
|
45
|
+
try {
|
|
46
|
+
Promise.resolve(fn()).catch((err) => {
|
|
47
|
+
ctx.print_debug(`Failed to reload: ${err}`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
ctx.print_debug(`Failed to reload: ${err}`);
|
|
52
|
+
}
|
|
53
|
+
}, debounceMs);
|
|
54
|
+
};
|
|
55
|
+
const cancel = () => {
|
|
56
|
+
if (timer) {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
timer = null;
|
|
46
59
|
}
|
|
47
|
-
}, 300);
|
|
48
|
-
};
|
|
49
|
-
try {
|
|
50
|
-
const watcher = fs.watch(initDataPath, { persistent: false }, onChange);
|
|
51
|
-
return {
|
|
52
|
-
close: () => watcher.close()
|
|
53
60
|
};
|
|
61
|
+
return { trigger, cancel };
|
|
62
|
+
}
|
|
63
|
+
ctx.print_debug('Enabling auto reload of init-data.json');
|
|
64
|
+
const dataReload = makeDebounced(reload, 'Config file changed, reloading...');
|
|
65
|
+
// Watch init-data.json with fs.watch (+ fs.watchFile fallback).
|
|
66
|
+
let closeDataWatcher;
|
|
67
|
+
try {
|
|
68
|
+
const watcher = fs.watch(initDataPath, { persistent: false }, dataReload.trigger);
|
|
69
|
+
closeDataWatcher = () => watcher.close();
|
|
54
70
|
}
|
|
55
71
|
catch (err) {
|
|
56
72
|
ctx.print_debug(`fs.watch unavailable; falling back to fs.watchFile: ${err}`);
|
|
57
|
-
fs.watchFile(initDataPath, { interval: 2000 },
|
|
58
|
-
|
|
59
|
-
|
|
73
|
+
fs.watchFile(initDataPath, { interval: 2000 }, dataReload.trigger);
|
|
74
|
+
closeDataWatcher = () => fs.unwatchFile(initDataPath, dataReload.trigger);
|
|
75
|
+
}
|
|
76
|
+
// Watch *.njk files under configPath with chokidar (cross-platform recursive).
|
|
77
|
+
let closeTemplateWatcher = null;
|
|
78
|
+
if (reloadForce) {
|
|
79
|
+
ctx.print_debug('Enabling auto reload of template files');
|
|
80
|
+
const templateReload = makeDebounced(reloadForce, 'Template file changed, reloading...');
|
|
81
|
+
const watcher = chokidarWatch(path.join(configPath, '**', '*.njk'), {
|
|
82
|
+
persistent: false,
|
|
83
|
+
ignoreInitial: true
|
|
84
|
+
});
|
|
85
|
+
watcher.on('add', templateReload.trigger);
|
|
86
|
+
watcher.on('change', templateReload.trigger);
|
|
87
|
+
closeTemplateWatcher = () => {
|
|
88
|
+
templateReload.cancel();
|
|
89
|
+
void watcher.close();
|
|
60
90
|
};
|
|
61
91
|
}
|
|
92
|
+
return {
|
|
93
|
+
close: () => {
|
|
94
|
+
dataReload.cancel();
|
|
95
|
+
closeDataWatcher();
|
|
96
|
+
closeTemplateWatcher?.();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
62
99
|
}
|
|
63
100
|
export class mailStore {
|
|
64
101
|
env;
|
|
@@ -69,6 +106,9 @@ export class mailStore {
|
|
|
69
106
|
uploadTemplate;
|
|
70
107
|
uploadStagingPath;
|
|
71
108
|
autoReloadHandle = null;
|
|
109
|
+
reloadInProgress = false;
|
|
110
|
+
reloadQueued = false;
|
|
111
|
+
reloadQueuedForce = false;
|
|
72
112
|
print_debug(msg) {
|
|
73
113
|
if (this.vars.DEBUG) {
|
|
74
114
|
console.log(msg);
|
|
@@ -77,6 +117,35 @@ export class mailStore {
|
|
|
77
117
|
config_filename(name) {
|
|
78
118
|
return path.resolve(path.join(this.configpath, name));
|
|
79
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Trigger an importData reload. If a reload is already in progress the request is
|
|
122
|
+
* queued (at most one pending run) so no reload is silently dropped. Returns
|
|
123
|
+
* 'triggered' when a new run starts, or 'queued' when one is already running.
|
|
124
|
+
*/
|
|
125
|
+
triggerReload(force = false) {
|
|
126
|
+
if (this.reloadInProgress) {
|
|
127
|
+
this.reloadQueued = true;
|
|
128
|
+
if (force)
|
|
129
|
+
this.reloadQueuedForce = true;
|
|
130
|
+
this.print_debug(`Reload already in progress; queued (force=${force})`);
|
|
131
|
+
return 'queued';
|
|
132
|
+
}
|
|
133
|
+
this.reloadInProgress = true;
|
|
134
|
+
this.print_debug(`Triggering reload (force=${force})`);
|
|
135
|
+
const fn = force ? () => importData(this, { force: true }) : () => importData(this);
|
|
136
|
+
Promise.resolve(fn())
|
|
137
|
+
.catch((err) => this.print_debug(`Reload failed: ${err}`))
|
|
138
|
+
.finally(() => {
|
|
139
|
+
this.reloadInProgress = false;
|
|
140
|
+
if (this.reloadQueued) {
|
|
141
|
+
this.reloadQueued = false;
|
|
142
|
+
const queued = this.reloadQueuedForce;
|
|
143
|
+
this.reloadQueuedForce = false;
|
|
144
|
+
this.triggerReload(queued);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return 'triggered';
|
|
148
|
+
}
|
|
80
149
|
resolveUploadPath(domainName) {
|
|
81
150
|
const raw = this.vars.UPLOAD_PATH ?? '';
|
|
82
151
|
const hasDomainToken = raw.includes('{domain}');
|
|
@@ -194,7 +263,11 @@ export class mailStore {
|
|
|
194
263
|
this.transport = await create_mail_transport(this.vars);
|
|
195
264
|
this.api_db = await connect_api_db(this);
|
|
196
265
|
this.autoReloadHandle?.close();
|
|
197
|
-
this.autoReloadHandle = enableInitDataAutoReload(this, () =>
|
|
266
|
+
this.autoReloadHandle = enableInitDataAutoReload(this, () => {
|
|
267
|
+
this.triggerReload(false);
|
|
268
|
+
}, () => {
|
|
269
|
+
this.triggerReload(true);
|
|
270
|
+
});
|
|
198
271
|
return this;
|
|
199
272
|
}
|
|
200
273
|
}
|
package/dist/esm/swagger.d.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import type { mailApiServer } from './server.js';
|
|
2
2
|
type SwaggerInstallOptions = {
|
|
3
|
-
apiBasePath: string;
|
|
4
|
-
assetRoute: string;
|
|
5
3
|
apiUrl: string;
|
|
6
4
|
swaggerEnabled?: boolean;
|
|
7
|
-
swaggerPath?: string;
|
|
8
5
|
};
|
|
9
6
|
export declare function installMailMagicSwagger(server: mailApiServer, opts: SwaggerInstallOptions): void;
|
|
10
7
|
export {};
|
package/dist/esm/swagger.js
CHANGED
|
@@ -1,44 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
5
|
-
function replacePrefix(input, from, to) {
|
|
6
|
-
if (input === from) {
|
|
7
|
-
return to;
|
|
8
|
-
}
|
|
9
|
-
if (input.startsWith(`${from}/`)) {
|
|
10
|
-
const suffix = input.slice(from.length);
|
|
11
|
-
if (to === '/') {
|
|
12
|
-
return suffix.replace(/^\/+/, '/') || '/';
|
|
13
|
-
}
|
|
14
|
-
return `${to}${suffix}`;
|
|
15
|
-
}
|
|
16
|
-
return input;
|
|
17
|
-
}
|
|
4
|
+
import { MAIL_MAGIC_SWAGGER_PATH } from './util/route.js';
|
|
18
5
|
function rewriteSpecForRuntime(spec, opts) {
|
|
19
6
|
if (!spec || typeof spec !== 'object') {
|
|
20
7
|
return spec;
|
|
21
8
|
}
|
|
22
|
-
const base = normalizeRoute(opts.apiBasePath, '/api');
|
|
23
|
-
const asset = normalizeRoute(opts.assetRoute, '/asset');
|
|
24
9
|
const root = spec;
|
|
25
10
|
const out = { ...root };
|
|
26
|
-
// Keep the spec stable while still reflecting the configured public URL
|
|
11
|
+
// Keep the spec stable while still reflecting the configured public URL.
|
|
27
12
|
out.servers = [{ url: String(opts.apiUrl || ''), description: 'Configured API_URL' }];
|
|
28
|
-
const rawPaths = root.paths;
|
|
29
|
-
if (!rawPaths || typeof rawPaths !== 'object') {
|
|
30
|
-
return out;
|
|
31
|
-
}
|
|
32
|
-
const rewritten = {};
|
|
33
|
-
for (const [p, v] of Object.entries(rawPaths)) {
|
|
34
|
-
let next = String(p);
|
|
35
|
-
next = replacePrefix(next, '/api', base);
|
|
36
|
-
next = replacePrefix(next, '/asset', asset);
|
|
37
|
-
// Normalize double slashes after prefix replacement (path only, not URLs).
|
|
38
|
-
next = next.replace(/\/{2,}/g, '/');
|
|
39
|
-
rewritten[next] = v;
|
|
40
|
-
}
|
|
41
|
-
out.paths = rewritten;
|
|
42
13
|
return out;
|
|
43
14
|
}
|
|
44
15
|
let cachedSpec = null;
|
|
@@ -60,16 +31,11 @@ function loadPackagedOpenApiSpec() {
|
|
|
60
31
|
}
|
|
61
32
|
}
|
|
62
33
|
export function installMailMagicSwagger(server, opts) {
|
|
63
|
-
|
|
64
|
-
const enabled = Boolean(opts.swaggerEnabled) || rawPath.length > 0;
|
|
65
|
-
if (!enabled) {
|
|
34
|
+
if (!opts.swaggerEnabled) {
|
|
66
35
|
return;
|
|
67
36
|
}
|
|
68
|
-
const base = normalizeRoute(opts.apiBasePath, '/api');
|
|
69
|
-
const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
|
|
70
|
-
const mount = normalizeRoute(resolved, `${base}/swagger`);
|
|
71
37
|
// Mount under the API router so it runs before the API 404 handler.
|
|
72
|
-
server.useExpress(
|
|
38
|
+
server.useExpress(MAIL_MAGIC_SWAGGER_PATH, (req, res, next) => {
|
|
73
39
|
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
74
40
|
next();
|
|
75
41
|
return;
|
|
@@ -86,8 +52,6 @@ export function installMailMagicSwagger(server, opts) {
|
|
|
86
52
|
return;
|
|
87
53
|
}
|
|
88
54
|
res.status(200).json(rewriteSpecForRuntime(spec, {
|
|
89
|
-
apiBasePath: base,
|
|
90
|
-
assetRoute: opts.assetRoute,
|
|
91
55
|
apiUrl: opts.apiUrl
|
|
92
56
|
}));
|
|
93
57
|
});
|
package/dist/esm/util/route.d.ts
CHANGED
package/dist/esm/util/route.js
CHANGED
package/dist/esm/util/utils.d.ts
CHANGED
|
@@ -19,6 +19,13 @@ export declare function user_and_domain(domain_id: number): Promise<{
|
|
|
19
19
|
user: api_user;
|
|
20
20
|
domain: api_domain;
|
|
21
21
|
}>;
|
|
22
|
+
/**
|
|
23
|
+
* Collect informational request metadata (client IP, IP chain, timestamp) for
|
|
24
|
+
* use in template rendering context. The values are **not** used for security
|
|
25
|
+
* decisions such as rate limiting — those rely on `getClientIp()` which is
|
|
26
|
+
* trust-proxy aware. For the IP chain to be meaningful the server must sit
|
|
27
|
+
* behind a trusted reverse proxy that sets the forwarded headers.
|
|
28
|
+
*/
|
|
22
29
|
export declare function buildRequestMeta(rawReq: unknown): RequestMeta;
|
|
23
30
|
export declare function decodeComponent(value: string | string[] | undefined): string;
|
|
24
31
|
export declare function getBodyValue(body: Record<string, unknown>, ...keys: string[]): string;
|
package/dist/esm/util/utils.js
CHANGED
|
@@ -60,6 +60,13 @@ function resolveHeader(headers, key) {
|
|
|
60
60
|
}
|
|
61
61
|
return undefined;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Collect informational request metadata (client IP, IP chain, timestamp) for
|
|
65
|
+
* use in template rendering context. The values are **not** used for security
|
|
66
|
+
* decisions such as rate limiting — those rely on `getClientIp()` which is
|
|
67
|
+
* trust-proxy aware. For the IP chain to be meaningful the server must sit
|
|
68
|
+
* behind a trusted reverse proxy that sets the forwarded headers.
|
|
69
|
+
*/
|
|
63
70
|
export function buildRequestMeta(rawReq) {
|
|
64
71
|
const req = (rawReq ?? {});
|
|
65
72
|
const headers = req.headers ?? {};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Mail Magic API",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "2.0.0-beta1",
|
|
6
6
|
"description": "OpenAPI definition for the Mail Magic server. Authenticated endpoints require an API key provided as `Authorization: Bearer apikey-<token>`."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"name": "public-assets",
|
|
33
|
-
"description": "Publicly served domain assets under
|
|
33
|
+
"description": "Publicly served domain assets under the fixed /asset route."
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"name": "swagger",
|
|
37
|
-
"description": "Swagger/OpenAPI spec endpoint
|
|
37
|
+
"description": "Swagger/OpenAPI spec endpoint at the fixed /api/swagger route when enabled."
|
|
38
38
|
}
|
|
39
39
|
],
|
|
40
40
|
"paths": {
|
|
@@ -699,6 +699,63 @@
|
|
|
699
699
|
}
|
|
700
700
|
}
|
|
701
701
|
},
|
|
702
|
+
"/api/v1/reload": {
|
|
703
|
+
"post": {
|
|
704
|
+
"tags": ["base"],
|
|
705
|
+
"summary": "Force reload templates and config",
|
|
706
|
+
"description": "Auth: API key. Triggers a force-reload of init-data metadata and all templates from disk. Returns whether the reload started immediately or was queued behind a reload already in progress.",
|
|
707
|
+
"security": [
|
|
708
|
+
{
|
|
709
|
+
"apiKeyBearer": []
|
|
710
|
+
}
|
|
711
|
+
],
|
|
712
|
+
"responses": {
|
|
713
|
+
"200": {
|
|
714
|
+
"description": "Reload request accepted.",
|
|
715
|
+
"content": {
|
|
716
|
+
"application/json": {
|
|
717
|
+
"schema": {
|
|
718
|
+
"allOf": [
|
|
719
|
+
{
|
|
720
|
+
"$ref": "#/components/schemas/ApiResponse"
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
"type": "object",
|
|
724
|
+
"properties": {
|
|
725
|
+
"data": {
|
|
726
|
+
"$ref": "#/components/schemas/ReloadResponseData"
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
"required": ["data"]
|
|
730
|
+
}
|
|
731
|
+
]
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
"401": {
|
|
737
|
+
"description": "Unauthorized.",
|
|
738
|
+
"content": {
|
|
739
|
+
"application/json": {
|
|
740
|
+
"schema": {
|
|
741
|
+
"$ref": "#/components/schemas/ApiResponse"
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
"500": {
|
|
747
|
+
"description": "Server error.",
|
|
748
|
+
"content": {
|
|
749
|
+
"application/json": {
|
|
750
|
+
"schema": {
|
|
751
|
+
"$ref": "#/components/schemas/ApiResponse"
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
},
|
|
702
759
|
"/asset/{domain}/{path}": {
|
|
703
760
|
"get": {
|
|
704
761
|
"tags": ["public-assets"],
|
|
@@ -751,7 +808,7 @@
|
|
|
751
808
|
"get": {
|
|
752
809
|
"tags": ["public-assets"],
|
|
753
810
|
"summary": "Fetch a public asset (under API base path)",
|
|
754
|
-
"description": "Compatibility route for
|
|
811
|
+
"description": "Compatibility route for clients that fetch assets under /api/asset.",
|
|
755
812
|
"security": [],
|
|
756
813
|
"parameters": [
|
|
757
814
|
{
|
|
@@ -797,7 +854,7 @@
|
|
|
797
854
|
"get": {
|
|
798
855
|
"tags": ["swagger"],
|
|
799
856
|
"summary": "Get OpenAPI spec",
|
|
800
|
-
"description": "Returns the OpenAPI spec JSON when Swagger is enabled. This endpoint is not wrapped in ApiResponse.",
|
|
857
|
+
"description": "Returns the OpenAPI spec JSON at the fixed /api/swagger route when Swagger is enabled. This endpoint is not wrapped in ApiResponse.",
|
|
801
858
|
"security": [],
|
|
802
859
|
"responses": {
|
|
803
860
|
"200": {
|
|
@@ -891,6 +948,21 @@
|
|
|
891
948
|
},
|
|
892
949
|
"required": ["Status"]
|
|
893
950
|
},
|
|
951
|
+
"ReloadResponseData": {
|
|
952
|
+
"type": "object",
|
|
953
|
+
"properties": {
|
|
954
|
+
"Status": {
|
|
955
|
+
"type": "string",
|
|
956
|
+
"examples": ["OK"]
|
|
957
|
+
},
|
|
958
|
+
"reload": {
|
|
959
|
+
"type": "string",
|
|
960
|
+
"enum": ["triggered", "queued"],
|
|
961
|
+
"description": "Whether a new force-reload started immediately or was queued behind one already in progress."
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
"required": ["Status", "reload"]
|
|
965
|
+
},
|
|
894
966
|
"TxTemplateUpsertRequest": {
|
|
895
967
|
"type": "object",
|
|
896
968
|
"additionalProperties": false,
|
package/docs/tutorial.md
CHANGED
|
@@ -22,7 +22,7 @@ Update your `.env` (or runtime environment) to point at the new workspace:
|
|
|
22
22
|
```dotenv
|
|
23
23
|
API_TOKEN_PEPPER=<generate-a-long-random-string>
|
|
24
24
|
CONFIG_PATH=${CONFIG_ROOT}
|
|
25
|
-
DB_AUTO_RELOAD=1 # optional: hot-reload init-data and
|
|
25
|
+
DB_AUTO_RELOAD=1 # optional: hot-reload init-data.json and template files
|
|
26
26
|
UPLOAD_PATH=./{domain}/uploads
|
|
27
27
|
```
|
|
28
28
|
|
|
@@ -64,8 +64,8 @@ myorg-config/
|
|
|
64
64
|
|
|
65
65
|
> **Assets vs inline:** Any file referenced via `asset('...')` must live under `myorg.com/assets/`. The helper
|
|
66
66
|
> `asset('logo.png')` will become `http://localhost:3776/asset/myorg.com/logo.png` by default. You can change the base
|
|
67
|
-
> via `ASSET_PUBLIC_BASE` (or `API_URL`)
|
|
68
|
-
>
|
|
67
|
+
> via `ASSET_PUBLIC_BASE` (or `API_URL`). Use `asset('logo.png', true)` when you need the file embedded as a CID
|
|
68
|
+
> attachment instead.
|
|
69
69
|
|
|
70
70
|
---
|
|
71
71
|
|
|
@@ -327,7 +327,8 @@ The inline flag (`true`) in `asset('logo.png', true)` tells Mail Magic to attach
|
|
|
327
327
|
}'
|
|
328
328
|
```
|
|
329
329
|
|
|
330
|
-
With `DB_AUTO_RELOAD=1`,
|
|
330
|
+
With `DB_AUTO_RELOAD=1`, saving `init-data.json` re-imports domain/user/template metadata; saving any `.njk` template
|
|
331
|
+
file forces a full template re-render including inline asset collection.
|
|
331
332
|
|
|
332
333
|
You now have a clean, self-contained configuration for MyOrg that inherits Mail Magic behaviour while keeping templates,
|
|
333
334
|
partials, and assets under version control in a dedicated folder.
|
package/examples/.env-dist
CHANGED
package/package.json
CHANGED
|
@@ -1,91 +1,94 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
2
|
+
"name": "@technomoron/mail-magic",
|
|
3
|
+
"version": "2.0.0-beta1",
|
|
4
|
+
"main": "dist/cjs/index.js",
|
|
5
|
+
"module": "dist/esm/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mail-magic": "dist/esm/bin/mail-magic.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/cjs/index.d.ts",
|
|
13
|
+
"import": "./dist/esm/index.js",
|
|
14
|
+
"require": "./dist/cjs/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"docs",
|
|
20
|
+
"examples",
|
|
21
|
+
"README.md",
|
|
22
|
+
"CHANGES"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/technomoron/mail-magic.git",
|
|
27
|
+
"directory": "packages/server"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"start": "node dist/esm/index.js",
|
|
31
|
+
"dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --watch 'config/**/*.*' --watch '.env' --exec 'tsx' src/index.ts",
|
|
32
|
+
"run": "NODE_ENV=production run-s start",
|
|
33
|
+
"sync:shared": "node ../../scripts/sync-shared-code.cjs >/dev/null",
|
|
34
|
+
"build:esm": "tsc --project tsconfig/tsconfig.esm.json",
|
|
35
|
+
"build:cjs": "node scripts/add-shebang.cjs --cjs-only",
|
|
36
|
+
"build": "run-s sync:shared build:esm build:cjs",
|
|
37
|
+
"postbuild": "node scripts/add-shebang.cjs",
|
|
38
|
+
"prepack": "run-s build",
|
|
39
|
+
"test:unit": "vitest run --silent --reporter=dot",
|
|
40
|
+
"test": "run-s --silent sync:shared test:unit",
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"scrub": "rimraf ./node_modules/ ./dist/ pnpm-lock.yaml package-lock.json yarn.lock",
|
|
43
|
+
"lint": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs --no-error-on-unmatched-pattern ./",
|
|
44
|
+
"lintfix": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs --fix --no-error-on-unmatched-pattern ./",
|
|
45
|
+
"pretty": "node ../../node_modules/prettier/bin/prettier.cjs --config ../../.prettierrc --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,md}\"",
|
|
46
|
+
"format": "run-s lintfix pretty",
|
|
47
|
+
"cleanbuild": "run-s clean:dist format build",
|
|
48
|
+
"lintconfig": "node ../../lintconfig.cjs",
|
|
49
|
+
"clean:dist": "rimraf ./dist/",
|
|
50
|
+
"release": "bash ../../scripts/release-package.sh .",
|
|
51
|
+
"release:check": "bash ../../scripts/release-package-check.sh .",
|
|
52
|
+
"release:preflight": "bash ../../scripts/release-package-preflight.sh ."
|
|
53
|
+
},
|
|
54
|
+
"keywords": [],
|
|
55
|
+
"author": "Bjørn Erik Jacobsen",
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"copyright": "Copyright (c) 2025 Bjørn Erik Jacobsen",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/technomoron/mail-magic/issues"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@technomoron/api-server-base": "2.0.0-beta.24",
|
|
63
|
+
"@technomoron/env-loader": "^1.0.8",
|
|
64
|
+
"@technomoron/unyuck": "^1.0.4",
|
|
65
|
+
"bcryptjs": "^3.0.2",
|
|
66
|
+
"chokidar": "^5.0.0",
|
|
67
|
+
"dotenv": "^16.4.5",
|
|
68
|
+
"email-addresses": "^5.0.0",
|
|
69
|
+
"html-to-text": "^9.0.5",
|
|
70
|
+
"nanoid": "^5.1.6",
|
|
71
|
+
"nodemailer": "^6.10.1",
|
|
72
|
+
"nunjucks": "^3.2.4",
|
|
73
|
+
"sequelize": "^6.37.7",
|
|
74
|
+
"sqlite3": "^5.1.7",
|
|
75
|
+
"swagger-jsdoc": "^6.2.8",
|
|
76
|
+
"swagger-ui-express": "^5.0.1",
|
|
77
|
+
"zod": "^4.1.5"
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@types/express": "^5.0.6",
|
|
81
|
+
"@types/html-to-text": "^9.0.4",
|
|
82
|
+
"@types/nodemailer": "^6.4.19",
|
|
83
|
+
"@types/nunjucks": "^3.2.6",
|
|
84
|
+
"@types/supertest": "^6.0.3",
|
|
85
|
+
"mailparser": "^3.9.1",
|
|
86
|
+
"nodemon": "^3.1.10",
|
|
87
|
+
"smtp-server": "^3.18.0",
|
|
88
|
+
"supertest": "^7.1.4",
|
|
89
|
+
"tsx": "^4.20.5",
|
|
90
|
+
"typescript": "^5.9.3",
|
|
91
|
+
"vitest": "^4.0.16"
|
|
92
|
+
},
|
|
93
|
+
"homepage": "https://github.com/technomoron/mail-magic#readme"
|
|
94
|
+
}
|