@technomoron/mail-magic 1.0.15 → 1.0.23

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,3 +1,51 @@
1
+ Version 1.0.23 (2026-02-07)
2
+
3
+ - Added a form recipient allowlist table plus `POST /api/v1/form/recipient` to map a
4
+ public recipient `idname` to an email address (with optional display name),
5
+ scoped per domain and optionally per `form_key`.
6
+ - `POST /api/v1/form/message` now supports `recipient_idname` so public forms can
7
+ select a configured recipient without exposing email addresses client-side.
8
+ - Added integration tests covering recipient allowlist fallbacks, overrides, locale scoping,
9
+ validation, and ownership checks.
10
+
11
+ Version 1.0.22 (2026-02-07)
12
+
13
+ - Added opt-in anti-abuse controls for public form submissions, including:
14
+ `UPLOAD_MAX`, in-memory IP rate limiting, optional CAPTCHA verification
15
+ (Turnstile/hCaptcha/reCAPTCHA) with per-form `captcha_required`, optional
16
+ attachment count limits, and optional upload cleanup.
17
+
18
+ Version 1.0.21 (2026-02-07)
19
+
20
+ - Added `AUTOESCAPE_HTML` (default: true) to control Nunjucks HTML autoescaping
21
+ when rendering transactional and form templates.
22
+
23
+ Version 1.0.20 (2026-02-07)
24
+
25
+ - Centralized env parsing in `mailStore` (no direct `process.env` reads in core
26
+ server code).
27
+
28
+ Version 1.0.19 (2026-02-07)
29
+
30
+ - Store API tokens as HMAC-SHA256 (with required `API_TOKEN_PEPPER`) instead of
31
+ plaintext, and migrate legacy plaintext tokens on startup.
32
+
33
+ Version 1.0.18 (2026-02-07)
34
+
35
+ - Added a stable `form_key` (generated via nanoid) to uniquely identify forms and
36
+ return it from `POST /api/v1/form/template`.
37
+ - Updated `POST /api/v1/form/message` form lookup to use `form_key` (preferred),
38
+ otherwise require `domain` + `formid` (+ optional `locale`) to avoid ambiguous
39
+ cross-domain/locale matches.
40
+
41
+ Version 1.0.17 (2026-02-07)
42
+
43
+ - Reduced sensitive logging by disabling env dumps unless `DEBUG` is enabled,
44
+ removing API tokens from debug output and API errors, and redacting DB
45
+ credentials in debug logs.
46
+ - Fixed TypeScript build issues by aligning Express typings and updating
47
+ Express-related utility types.
48
+
1
49
  Version 1.0.15 (2026-02-01)
2
50
 
3
51
  - Made the optional admin UI/API opt-in via `ADMIN_ENABLED`, delegating admin
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @technomoron/mail-magic
2
2
 
3
- Mail Magic is a TypeScript service for managing, templating, and delivering transactional emails. It exposes a small REST API built on `@technomoron/api-server-base`, persists data with Sequelize/SQLite, and renders outbound messages with Nunjucks templates.
3
+ Mail Magic is a TypeScript service for managing, templating, and delivering transactional emails. It exposes a small
4
+ REST API built on `@technomoron/api-server-base`, persists data with Sequelize/SQLite, and renders outbound messages
5
+ with Nunjucks templates.
4
6
 
5
7
  ## Features
6
8
 
@@ -16,7 +18,8 @@ Mail Magic is a TypeScript service for managing, templating, and delivering tran
16
18
  1. Clone the repository: `git clone git@github.com:technomoron/mail-magic.git`
17
19
  2. Install dependencies: `npm install`
18
20
  3. Create your environment file: copy `.env-dist` to `.env` and adjust values
19
- 4. Populate the config directory (defaults to `./data/`; see `config-example/` for a reference layout). You can point `CONFIG_PATH` at `./config` to use the bundled sample data.
21
+ 4. Populate the config directory (defaults to `./data/`; see `config-example/` for a reference layout). You can point
22
+ `CONFIG_PATH` at `./config` to use the bundled sample data.
20
23
  5. Build the project: `npm run build`
21
24
  6. Start the API server: `npm run start`
22
25
 
@@ -24,22 +27,38 @@ During development you can run `npm run dev` for a watch mode that recompiles on
24
27
 
25
28
  ## Configuration
26
29
 
27
- - **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API host/port, the config directory path, database options, and `ADMIN_ENABLED`/`ADMIN_APP_PATH` to control the admin UI/API.
28
- - **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template assets. Each domain now lives directly under the config root (for example `data/example.com/form-template/…`). Use an absolute path or a relative one like `../data` when you want the config outside the repo. Review `config-example/` for the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks templates.
29
- - **Database** defaults to SQLite (`maildata.db`). You can switch dialects by updating the environment options if your deployment requires another database.
30
- - **Uploads** default to `<CONFIG_PATH>/<domain>/uploads` via `UPLOAD_PATH=./{domain}/uploads`. Set a fixed path if you prefer a shared upload directory.
31
-
32
- When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a restart.
30
+ - **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API
31
+ host/port, the config directory path, database options, and `ADMIN_ENABLED`/`ADMIN_APP_PATH` to control the admin
32
+ UI/API.
33
+ - **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template
34
+ assets. Each domain now lives directly under the config root (for example `data/example.com/form-template/…`). Use an
35
+ absolute path or a relative one like `../data` when you want the config outside the repo. Review `config-example/` for
36
+ the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks
37
+ templates.
38
+ - **Database** defaults to SQLite (`maildata.db`). You can switch dialects by updating the environment options if your
39
+ deployment requires another database.
40
+ - **Uploads** default to `<CONFIG_PATH>/<domain>/uploads` via `UPLOAD_PATH=./{domain}/uploads`. Set a fixed path if you
41
+ prefer a shared upload directory.
42
+
43
+ When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a
44
+ restart.
33
45
 
34
46
  ### Admin UI
35
47
 
36
- The server mounts the admin UI at `/` only when `ADMIN_ENABLED` is true and the `@technomoron/mail-magic-admin` package is installed. You can point `ADMIN_APP_PATH` at a dist folder (or its parent) to override the package-provided build. The admin API module is loaded from the admin package as well. This is a placeholder Vue app today, but it is already wired so future admin features can live there without changing the server routing.
48
+ The server mounts the admin UI at `/` only when `ADMIN_ENABLED` is true and the `@technomoron/mail-magic-admin` package
49
+ is installed. You can point `ADMIN_APP_PATH` at a dist folder (or its parent) to override the package-provided build.
50
+ The admin API module is loaded from the admin package as well. This is a placeholder Vue app today, but it is already
51
+ wired so future admin features can live there without changing the server routing.
37
52
 
38
53
  ### Template assets and inline resources
39
54
 
40
- - Keep any non-inline files (images, attachments, etc.) under `<CONFIG_PATH>/<domain>/assets`. Mail Magic rewrites `asset('logo.png')` using `ASSET_ROUTE` (default `/asset`) and a base URL from `ASSET_PUBLIC_BASE` (or `API_URL` if unset). The default output looks like `http://localhost:3776/asset/<domain>/logo.png`.
41
- - Pass `true` as the second argument when you want to embed a file as an inline CID attachment: `asset('logo.png', true)` stores the file in Nodemailer and rewrites the HTML to reference `cid:logo.png`.
42
- - Avoid mixing template-type folders for assets; everything that should be linked externally belongs in the shared `<domain>/assets` tree so it can be served for both form and transactional templates.
55
+ - Keep any non-inline files (images, attachments, etc.) under `<CONFIG_PATH>/<domain>/assets`. Mail Magic rewrites
56
+ `asset('logo.png')` using `ASSET_ROUTE` (default `/asset`) and a base URL from `ASSET_PUBLIC_BASE` (or `API_URL` if
57
+ unset). The default output looks like `http://localhost:3776/asset/<domain>/logo.png`.
58
+ - Pass `true` as the second argument when you want to embed a file as an inline CID attachment:
59
+ `asset('logo.png', true)` stores the file in Nodemailer and rewrites the HTML to reference `cid:logo.png`.
60
+ - Avoid mixing template-type folders for assets; everything that should be linked externally belongs in the shared
61
+ `<domain>/assets` tree so it can be served for both form and transactional templates.
43
62
 
44
63
  ## API Overview
45
64
 
@@ -52,7 +71,8 @@ The server mounts the admin UI at `/` only when `ADMIN_ENABLED` is true and the
52
71
 
53
72
  All routes are mounted under `API_BASE_PATH` (default `/api`), so the full path is typically `/api/v1/...`.
54
73
 
55
- All authenticated routes expect an API token associated with a configured user. Attachments can be uploaded alongside the `/v1/tx/message` request and are forwarded by Nodemailer.
74
+ All authenticated routes expect an API token associated with a configured user. Attachments can be uploaded alongside
75
+ the `/v1/tx/message` request and are forwarded by Nodemailer.
56
76
 
57
77
  ## Available Scripts
58
78
 
@@ -144,17 +144,27 @@ export class AssetAPI extends ApiModule {
144
144
  }
145
145
  }
146
146
  export function createAssetHandler(server) {
147
- return async (req, res) => {
147
+ return async (req, res, next) => {
148
+ if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
149
+ if (next) {
150
+ next();
151
+ return;
152
+ }
153
+ res.status(405).end();
154
+ return;
155
+ }
148
156
  const domain = decodeComponent(req?.params?.domain);
149
157
  if (!domain || !DOMAIN_PATTERN.test(domain)) {
150
158
  res.status(404).end();
151
159
  return;
152
160
  }
153
- const rawPath = typeof req?.params?.[0] === 'string' ? req.params[0] : '';
154
- const segments = rawPath
155
- .split('/')
156
- .filter(Boolean)
157
- .map((segment) => decodeComponent(segment));
161
+ const rawPathParam = req?.params?.path ?? req?.params?.[0];
162
+ const rawSegments = Array.isArray(rawPathParam)
163
+ ? rawPathParam
164
+ : typeof rawPathParam === 'string'
165
+ ? rawPathParam.split('/').filter(Boolean)
166
+ : [];
167
+ const segments = rawSegments.map((segment) => decodeComponent(typeof segment === 'string' ? segment : ''));
158
168
  if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
159
169
  res.status(404).end();
160
170
  return;
@@ -191,9 +201,10 @@ export function createAssetHandler(server) {
191
201
  return;
192
202
  }
193
203
  res.type(path.extname(realCandidate));
194
- res.set('Cache-Control', 'public, max-age=300');
195
204
  try {
196
- await sendFileAsync(res, realCandidate);
205
+ // Express' `sendFile()` sets Cache-Control based on `maxAge` (in ms). Setting the header
206
+ // before calling `sendFile()` can be overwritten by Express defaults.
207
+ await sendFileAsync(res, realCandidate, { maxAge: 300_000 });
197
208
  }
198
209
  catch (err) {
199
210
  server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
package/dist/api/auth.js CHANGED
@@ -20,9 +20,14 @@ export async function assert_domain_and_user(apireq) {
20
20
  if (!domain) {
21
21
  throw new ApiError({ code: 401, message: 'Missing domain' });
22
22
  }
23
- const user = await api_user.findOne({ where: { token: apireq.token } });
23
+ const rawUid = apireq.getRealUid();
24
+ const uid = rawUid === null ? null : Number(rawUid);
25
+ if (!uid || Number.isNaN(uid)) {
26
+ throw new ApiError({ code: 401, message: 'Invalid/Unknown API Key/Token' });
27
+ }
28
+ const user = await api_user.findByPk(uid);
24
29
  if (!user) {
25
- throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
30
+ throw new ApiError({ code: 401, message: 'Invalid/Unknown API Key/Token' });
26
31
  }
27
32
  const dbdomain = await api_domain.findOne({ where: { name: domain } });
28
33
  if (!dbdomain) {