@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 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
- Start from the repo’s `.env-dist` and set at least:
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`, `SWAGGER_PATH`
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
- - optionally set `SWAGGER_PATH` (defaults to `<API_BASE_PATH>/swagger`, typically `/api/swagger`)
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 `ASSET_ROUTE` (default `/asset`)
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:
@@ -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,7 @@
1
+ import { ApiModule, ApiRoute } from '@technomoron/api-server-base';
2
+ import { mailApiServer } from '../server.js';
3
+ export declare class ReloadAPI extends ApiModule<mailApiServer> {
4
+ private assertUser;
5
+ private postReload;
6
+ defineRoutes(): ApiRoute[];
7
+ }
@@ -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
@@ -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 { normalizeRoute } from './util/route.js';
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, swaggerPath } = config;
51
+ const { swaggerEnabled } = config;
54
52
  const serverConfig = { ...config, swaggerEnabled: false, swaggerPath: '' };
55
- const server = new mailApiServer(serverConfig, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
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 a public route with traversal protection and caching.
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 assetMounts = new Set();
70
- assetMounts.add(assetPrefix);
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: normalizeRoute(store.vars.API_BASE_PATH, '/api'),
135
- assetRoute: normalizeRoute(store.vars.ASSET_ROUTE, '/asset'),
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): Promise<void>;
11
+ export declare function importData(store: mailStore, options?: {
12
+ force?: boolean;
13
+ }): Promise<void>;
12
14
  export {};
@@ -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, assetRoute, domain.name, urlPath),
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: 'Reload init-data.json automatically on change',
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
- type: 'boolean',
47
- default: false
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>;
@@ -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
- ctx.print_debug('Enabling auto reload of init-data.json');
33
- let debounceTimer = null;
34
- const onChange = () => {
35
- if (debounceTimer) {
36
- clearTimeout(debounceTimer);
37
- }
38
- debounceTimer = setTimeout(() => {
39
- debounceTimer = null;
40
- ctx.print_debug('Config file changed, reloading...');
41
- try {
42
- reload();
43
- }
44
- catch (err) {
45
- ctx.print_debug(`Failed to reload config: ${err}`);
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 }, onChange);
58
- return {
59
- close: () => fs.unwatchFile(initDataPath, onChange)
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, () => importData(this));
266
+ this.autoReloadHandle = enableInitDataAutoReload(this, () => {
267
+ this.triggerReload(false);
268
+ }, () => {
269
+ this.triggerReload(true);
270
+ });
198
271
  return this;
199
272
  }
200
273
  }
@@ -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 {};
@@ -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 { normalizeRoute } from './util/route.js';
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 and base paths.
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
- const rawPath = typeof opts.swaggerPath === 'string' ? opts.swaggerPath.trim() : '';
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(mount, (req, res, next) => {
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
  });
@@ -1 +1,4 @@
1
+ export declare const MAIL_MAGIC_API_BASE_PATH = "/api";
2
+ export declare const MAIL_MAGIC_ASSET_ROUTE = "/asset";
3
+ export declare const MAIL_MAGIC_SWAGGER_PATH = "/api/swagger";
1
4
  export declare function normalizeRoute(value: string, fallback?: string): string;
@@ -1,3 +1,6 @@
1
+ export const MAIL_MAGIC_API_BASE_PATH = '/api';
2
+ export const MAIL_MAGIC_ASSET_ROUTE = '/asset';
3
+ export const MAIL_MAGIC_SWAGGER_PATH = '/api/swagger';
1
4
  export function normalizeRoute(value, fallback = '') {
2
5
  if (!value) {
3
6
  return fallback;
@@ -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;
@@ -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": "1.0.42",
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 ASSET_ROUTE."
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 (enabled via SWAGGER_ENABLED / SWAGGER_PATH)."
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 assets reachable under API_BASE_PATH + ASSET_ROUTE.",
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 templates
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`) and the route via `ASSET_ROUTE`. Use `asset('logo.png', true)` when you need
68
- > the file embedded as a CID attachment instead.
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`, editing templates or assets is as simple as saving the file.
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.
@@ -1,9 +1,7 @@
1
1
  NODE_ENV=development
2
2
  API_PORT=3776
3
3
  API_HOST=127.0.0.1
4
- API_URL=http://127.0.0.1:3776/api
5
- API_BASE_PATH=/api
6
- ASSET_ROUTE=/asset
4
+ API_URL=http://127.0.0.1:3776
7
5
  CONFIG_PATH=./data
8
6
  DB_TYPE=sqlite
9
7
  DB_NAME=./maildata.db
package/package.json CHANGED
@@ -1,91 +1,94 @@
1
1
  {
2
- "name": "@technomoron/mail-magic",
3
- "version": "1.0.43",
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
- "keywords": [],
30
- "author": "Bjørn Erik Jacobsen",
31
- "license": "MIT",
32
- "copyright": "Copyright (c) 2025 Bjørn Erik Jacobsen",
33
- "bugs": {
34
- "url": "https://github.com/technomoron/mail-magic/issues"
35
- },
36
- "dependencies": {
37
- "@technomoron/api-server-base": "2.0.0-beta.24",
38
- "@technomoron/env-loader": "^1.0.8",
39
- "@technomoron/unyuck": "^1.0.4",
40
- "bcryptjs": "^3.0.2",
41
- "dotenv": "^16.4.5",
42
- "email-addresses": "^5.0.0",
43
- "html-to-text": "^9.0.5",
44
- "nanoid": "^5.1.6",
45
- "nodemailer": "^6.10.1",
46
- "nunjucks": "^3.2.4",
47
- "sequelize": "^6.37.7",
48
- "sqlite3": "^5.1.7",
49
- "swagger-jsdoc": "^6.2.8",
50
- "swagger-ui-express": "^5.0.1",
51
- "zod": "^4.1.5"
52
- },
53
- "devDependencies": {
54
- "@types/express": "^5.0.6",
55
- "@types/html-to-text": "^9.0.4",
56
- "@types/nodemailer": "^6.4.19",
57
- "@types/nunjucks": "^3.2.6",
58
- "@types/supertest": "^6.0.3",
59
- "mailparser": "^3.9.1",
60
- "nodemon": "^3.1.10",
61
- "smtp-server": "^3.18.0",
62
- "supertest": "^7.1.4",
63
- "tsx": "^4.20.5",
64
- "typescript": "^5.9.3",
65
- "vitest": "^4.0.16"
66
- },
67
- "homepage": "https://github.com/technomoron/mail-magic#readme",
68
- "scripts": {
69
- "start": "node dist/esm/index.js",
70
- "dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --watch 'config/**/*.*' --watch '.env' --exec 'tsx' src/index.ts",
71
- "run": "NODE_ENV=production run-s start",
72
- "sync:shared": "node ../../scripts/sync-shared-code.cjs >/dev/null",
73
- "build:esm": "tsc --project tsconfig/tsconfig.esm.json",
74
- "build:cjs": "node scripts/add-shebang.cjs --cjs-only",
75
- "build": "run-s sync:shared build:esm build:cjs",
76
- "postbuild": "node scripts/add-shebang.cjs",
77
- "test:unit": "vitest run --silent --reporter=dot",
78
- "test": "run-s --silent sync:shared test:unit",
79
- "test:watch": "vitest",
80
- "scrub": "rimraf ./node_modules/ ./dist/ pnpm-lock.yaml package-lock.json yarn.lock",
81
- "lint": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs --no-error-on-unmatched-pattern ./",
82
- "lintfix": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs --fix --no-error-on-unmatched-pattern ./",
83
- "pretty": "node ../../node_modules/prettier/bin/prettier.cjs --config ../../.prettierrc --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,md}\"",
84
- "format": "run-s lintfix pretty",
85
- "cleanbuild": "run-s clean:dist format build",
86
- "lintconfig": "node ../../lintconfig.cjs",
87
- "clean:dist": "rimraf ./dist/",
88
- "release": "bash ../../scripts/release-package.sh .",
89
- "release:check": "bash ../../scripts/release-package-check.sh ."
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
+ }