@technomoron/mail-magic 1.0.23 → 1.0.33

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.
Files changed (41) hide show
  1. package/CHANGES +50 -0
  2. package/README.md +285 -70
  3. package/dist/api/assets.js +9 -56
  4. package/dist/api/auth.js +1 -12
  5. package/dist/api/form-replyto.js +44 -0
  6. package/dist/api/form-submission.js +95 -0
  7. package/dist/api/forms.js +262 -318
  8. package/dist/api/mailer.js +1 -1
  9. package/dist/bin/mail-magic.js +2 -2
  10. package/dist/index.js +30 -19
  11. package/dist/models/db.js +5 -4
  12. package/dist/models/domain.js +17 -8
  13. package/dist/models/form.js +110 -38
  14. package/dist/models/init.js +34 -74
  15. package/dist/models/recipient.js +12 -8
  16. package/dist/models/txmail.js +22 -25
  17. package/dist/models/user.js +14 -10
  18. package/dist/server.js +1 -1
  19. package/dist/store/envloader.js +9 -4
  20. package/dist/store/store.js +53 -22
  21. package/dist/swagger.js +107 -0
  22. package/dist/util/captcha.js +24 -0
  23. package/dist/util/email.js +19 -0
  24. package/dist/util/paths.js +41 -0
  25. package/dist/util/ratelimit.js +48 -0
  26. package/dist/util/uploads.js +48 -0
  27. package/dist/util/utils.js +151 -0
  28. package/dist/util.js +4 -127
  29. package/docs/config-example/example.test/assets/files/banner.png +1 -0
  30. package/docs/config-example/example.test/assets/images/logo.png +1 -0
  31. package/docs/config-example/example.test/form-template/base.njk +6 -0
  32. package/docs/config-example/example.test/form-template/contact.njk +9 -0
  33. package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
  34. package/docs/config-example/example.test/tx-template/base.njk +10 -0
  35. package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
  36. package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
  37. package/docs/config-example/init-data.json +57 -0
  38. package/docs/form-security.md +194 -0
  39. package/docs/swagger/openapi.json +1321 -0
  40. package/{TUTORIAL.MD → docs/tutorial.md} +127 -33
  41. package/package.json +3 -3
package/CHANGES CHANGED
@@ -1,3 +1,53 @@
1
+ Version 1.0.33 (2026-02-09)
2
+
3
+ - Route all env-derived config through `mailStore.vars` with explicit overrides for tests/examples.
4
+ - Move shared helpers into `util/` modules (rate limiting, email parsing, path safety, uploads).
5
+ - Switch template preprocessing to Unyuck’s asset collection while preserving existing URL and CID behavior.
6
+
7
+ Version 1.0.32 (2026-02-08)
8
+
9
+ - Add regression coverage for legacy plaintext API token migration to `token_hmac`.
10
+ - Publish `docs/swagger/openapi.json` and install a local swagger handler that is stable regardless of `process.cwd()`.
11
+ - Refactor public form submissions to use explicit `_mm_*` control fields (`_mm_form_key`, `_mm_locale`,
12
+ `_mm_recipients`) and stricter attachment field naming (`_mm_file*`).
13
+ - Centralize captcha verification in `util/captcha` and add contract-style test coverage for the public form endpoint.
14
+
15
+ Version 1.0.31 (2026-02-08)
16
+
17
+ - Add regression coverage for form lookup ambiguity handling and secret-gated recipient overrides.
18
+
19
+ Version 1.0.30 (2026-02-08)
20
+
21
+ - Add regression coverage for Nunjucks HTML autoescape behavior and the `|safe` filter.
22
+
23
+ Version 1.0.29 (2026-02-08)
24
+
25
+ - Add regression coverage for CAPTCHA verification flows on the public form endpoint.
26
+
27
+ Version 1.0.28 (2026-02-08)
28
+
29
+ - Add regression coverage for public form anti-abuse controls (rate limiting, attachment limits, and upload cleanup).
30
+
31
+ Version 1.0.27 (2026-02-08)
32
+
33
+ - Refresh README/TUTORIAL with current API routes, config layout, and security-related configuration (API token pepper,
34
+ form_key, recipient allowlist, and form anti-abuse options).
35
+ - Update `config-example/` to match the current init-data schema and domain-rooted config directory layout.
36
+
37
+ Version 1.0.26 (2026-02-07)
38
+
39
+ - Fix env/docs polish and remove unused code flagged by ESLint.
40
+
41
+ Version 1.0.25 (2026-02-07)
42
+
43
+ - Default `DB_AUTO_RELOAD` to false to avoid unexpected config reloads in production.
44
+ - Added `DB_SYNC_ALTER` to control Sequelize `sync({ alter: ... })` on startup.
45
+
46
+ Version 1.0.24 (2026-02-07)
47
+
48
+ - Validate imported domain names against a safe pattern to prevent config path
49
+ traversal via malformed domain identifiers.
50
+
1
51
  Version 1.0.23 (2026-02-07)
2
52
 
3
53
  - Added a form recipient allowlist table plus `POST /api/v1/form/recipient` to map a
package/README.md CHANGED
@@ -1,97 +1,312 @@
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
4
- REST API built on `@technomoron/api-server-base`, persists data with Sequelize/SQLite, and renders outbound messages
5
- with Nunjucks templates.
3
+ Mail Magic is a small TypeScript HTTP service that:
6
4
 
7
- ## Features
5
+ - stores transactional email templates and sends transactional messages (authenticated API)
6
+ - stores “contact form” templates and accepts public form submissions (unauthenticated endpoint)
7
+ - renders mail with Nunjucks, and delivers via Nodemailer
8
8
 
9
- - Upload, store, and send templated email content through a JSON API
10
- - Preprocess template assets with `@technomoron/unyuck` before persisting
11
- - Nodemailer transport configuration driven by environment variables
12
- - SQLite-backed data models for domains, users, forms, and templates
13
- - Type-safe configuration loader powered by `@technomoron/env-loader`
14
- - Bundled admin UI (placeholder) served at the root path `/`
9
+ This README is intended to be “enough to run and operate the service”. For exact request/response shapes and every
10
+ endpoint, use the OpenAPI spec described in **Swagger / OpenAPI** below.
15
11
 
16
- ## Getting Started
12
+ ## Contents
17
13
 
18
- 1. Clone the repository: `git clone git@github.com:technomoron/mail-magic.git`
19
- 2. Install dependencies: `npm install`
20
- 3. Create your environment file: copy `.env-dist` to `.env` and adjust values
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.
23
- 5. Build the project: `npm run build`
24
- 6. Start the API server: `npm run start`
14
+ - What You Get
15
+ - Install
16
+ - Quick Start
17
+ - Concepts
18
+ - Configuration
19
+ - Swagger / OpenAPI
20
+ - API Usage (Examples)
21
+ - Public Form Endpoint Contract
22
+ - Assets
23
+ - Security Notes
24
+ - Development (Repo)
25
25
 
26
- During development you can run `npm run dev` for a watch mode that recompiles on change and restarts via `nodemon`.
26
+ ## What You Get
27
+
28
+ - REST API built on `@technomoron/api-server-base`
29
+ - SQLite + Sequelize persistence by default (configurable)
30
+ - Nunjucks templating with optional HTML autoescape (`AUTOESCAPE_HTML=true` by default)
31
+ - Config tree on disk (`CONFIG_PATH`) for per-domain templates and assets
32
+ - Recipient allowlist so public forms can route to named recipients without exposing emails
33
+ - Optional anti-abuse controls on the public form endpoint (rate limiting, attachment limits, CAPTCHA)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ npm install @technomoron/mail-magic
39
+ ```
40
+
41
+ The package ships a `mail-magic` CLI that loads a `.env` file and starts the server.
42
+
43
+ ## Quick Start
44
+
45
+ ### 1. Create a `.env`
46
+
47
+ Start from the repo’s `.env-dist` and set at least:
48
+
49
+ ```ini
50
+ # REQUIRED. Keep stable: used to HMAC API tokens before DB lookup.
51
+ API_TOKEN_PEPPER=change-me-please-use-a-long-random-string
52
+
53
+ CONFIG_PATH=./data
54
+
55
+ API_HOST=127.0.0.1
56
+ API_PORT=3776
57
+ API_BASE_PATH=/api
58
+ API_URL=http://127.0.0.1:3776
59
+
60
+ SMTP_HOST=127.0.0.1
61
+ SMTP_PORT=1025
62
+ SMTP_SECURE=false
63
+ SMTP_TLS_REJECT=false
64
+ ```
65
+
66
+ ### 2. Create a minimal config directory
67
+
68
+ `CONFIG_PATH` points at a directory containing `init-data.json` plus per-domain subfolders:
69
+
70
+ ```text
71
+ data/
72
+ init-data.json
73
+ example.test/
74
+ assets/
75
+ images/logo.png
76
+ tx-template/
77
+ welcome.njk
78
+ form-template/
79
+ contact.njk
80
+ ```
81
+
82
+ Assets referenced via `asset('...')` must live under:
83
+
84
+ `<CONFIG_PATH>/<domain>/assets/...`
85
+
86
+ ### 3. Start the server
87
+
88
+ ```bash
89
+ mail-magic --env ./.env
90
+ ```
91
+
92
+ ## Concepts
93
+
94
+ ### Domain-first config layout
95
+
96
+ Mail Magic treats each domain as a root for templates and assets on disk:
97
+
98
+ - Transactional templates: `<CONFIG_PATH>/<domain>/tx-template/...`
99
+ - Form templates: `<CONFIG_PATH>/<domain>/form-template/...`
100
+ - Public assets: `<CONFIG_PATH>/<domain>/assets/...`
101
+
102
+ ### Transactional vs form messages
103
+
104
+ - Transactional:
105
+ - authenticated endpoints
106
+ - you choose recipient email(s) in the send request
107
+ - templates can use `_vars_` and `_rcpt_email_`
108
+ - Form:
109
+ - authenticated endpoint to store/update the form template and configuration
110
+ - unauthenticated endpoint to submit the form
111
+ - public submissions are identified by a random `form_key`
112
+
113
+ ### `form_key` (public identifier)
114
+
115
+ `POST /api/v1/form/template` returns a stable random `form_key`. Public submissions use that key as `_mm_form_key`.
116
+
117
+ Treat `form_key` as sensitive: anyone who has it can submit to that form.
118
+
119
+ ### Recipient allowlist
120
+
121
+ For “choose a recipient” forms, do not accept user-supplied emails. Instead:
122
+
123
+ 1. Configure recipients server-side with `POST /api/v1/form/recipient` (authenticated).
124
+ 2. In the public request, pass `_mm_recipients` containing recipient `idname`s.
125
+
126
+ The server resolves those `idname`s to real email addresses using the allowlist.
27
127
 
28
128
  ## Configuration
29
129
 
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.
130
+ Mail Magic is configured via environment variables plus the `CONFIG_PATH` directory.
131
+
132
+ The full set of environment variables is documented in the repository’s `.env-dist`.
133
+
134
+ Commonly used variables:
135
+
136
+ - `API_HOST`, `API_PORT`, `API_URL`
137
+ - `API_BASE_PATH` (default `/api`)
138
+ - `CONFIG_PATH` (default `./data/`)
139
+ - `ASSET_ROUTE` (default `/asset`)
140
+ - `ASSET_PUBLIC_BASE` (optional public base URL for assets)
141
+ - `AUTOESCAPE_HTML` (default `true`)
142
+ - `UPLOAD_PATH`, `UPLOAD_MAX` (multipart uploads)
143
+ - Public form anti-abuse:
144
+ - `FORM_RATE_LIMIT_WINDOW_SEC`, `FORM_RATE_LIMIT_MAX`
145
+ - `FORM_MAX_ATTACHMENTS`, `FORM_KEEP_UPLOADS`
146
+ - `FORM_CAPTCHA_PROVIDER`, `FORM_CAPTCHA_SECRET`, `FORM_CAPTCHA_REQUIRED`
147
+ - Swagger/OpenAPI:
148
+ - `SWAGGER_ENABLED`, `SWAGGER_PATH`
149
+
150
+ ## Swagger / OpenAPI
151
+
152
+ Mail Magic ships an OpenAPI JSON spec and can expose it at runtime.
153
+
154
+ Packaged spec (on disk):
155
+
156
+ - `node_modules/@technomoron/mail-magic/docs/swagger/openapi.json`
157
+
158
+ Runtime spec endpoint:
159
+
160
+ - set `SWAGGER_ENABLED=true`
161
+ - optionally set `SWAGGER_PATH` (defaults to `<API_BASE_PATH>/swagger`, typically `/api/swagger`)
162
+ - fetch the JSON from that endpoint and feed it to Swagger UI / Postman / Insomnia
163
+
164
+ This spec is the canonical reference for:
165
+
166
+ - the exact route list
167
+ - request/response bodies
168
+ - status codes and error shapes
169
+
170
+ ## API Usage (Examples)
171
+
172
+ All authenticated routes require:
173
+
174
+ ```text
175
+ Authorization: Bearer apikey-<user_token>
176
+ ```
177
+
178
+ Tokens are stored as `HMAC-SHA256(token, API_TOKEN_PEPPER)` in the DB. You can seed a plaintext `token` in
179
+ `init-data.json`; it will be HMACed on import and the plaintext cleared.
180
+
181
+ ### Transactional: store template (authenticated)
182
+
183
+ ```bash
184
+ curl -X POST http://localhost:3776/api/v1/tx/template \
185
+ -H "Authorization: Bearer apikey-<token>" \
186
+ -H "Content-Type: application/json" \
187
+ -d '{
188
+ "domain": "example.test",
189
+ "name": "welcome",
190
+ "sender": "Example <noreply@example.test>",
191
+ "subject": "Welcome",
192
+ "locale": "en",
193
+ "template": "<p>Hi {{ _vars_.first_name }}</p>"
194
+ }'
195
+ ```
196
+
197
+ ### Transactional: send message (authenticated)
198
+
199
+ ```bash
200
+ curl -X POST http://localhost:3776/api/v1/tx/message \
201
+ -H "Authorization: Bearer apikey-<token>" \
202
+ -H "Content-Type: application/json" \
203
+ -d '{
204
+ "domain": "example.test",
205
+ "name": "welcome",
206
+ "locale": "en",
207
+ "rcpt": ["person@example.test"],
208
+ "vars": { "first_name": "Ada" }
209
+ }'
210
+ ```
211
+
212
+ ### Forms: store form template (authenticated)
213
+
214
+ This returns `data.form_key` which is used by the public endpoint.
215
+
216
+ ```bash
217
+ curl -X POST http://localhost:3776/api/v1/form/template \
218
+ -H "Authorization: Bearer apikey-<token>" \
219
+ -H "Content-Type: application/json" \
220
+ -d '{
221
+ "domain": "example.test",
222
+ "idname": "contact",
223
+ "sender": "Example Forms <forms@example.test>",
224
+ "recipient": "owner@example.test",
225
+ "subject": "New contact form submission",
226
+ "locale": "en",
227
+ "template": "<p>Contact from {{ _fields_.name }} ({{ _fields_.email }})</p>"
228
+ }'
229
+ ```
230
+
231
+ ### Forms: submit (public endpoint, no auth)
232
+
233
+ ```bash
234
+ curl -X POST http://localhost:3776/api/v1/form/message \
235
+ -H "Content-Type: application/json" \
236
+ -d '{
237
+ "_mm_form_key": "<form_key from the template response>",
238
+ "name": "Kai",
239
+ "email": "kai@example.test",
240
+ "message": "Hello"
241
+ }'
242
+ ```
243
+
244
+ ## Public Form Endpoint Contract
245
+
246
+ Endpoint:
247
+
248
+ - `POST /api/v1/form/message` (no auth)
249
+
250
+ Required system fields:
251
+
252
+ - `_mm_form_key` (string)
253
+
254
+ Optional system fields:
255
+
256
+ - `_mm_locale` (string)
257
+ - `_mm_recipients` (string[] or comma-separated string)
258
+
259
+ CAPTCHA token fields (accepted as-is, provider-native):
260
+
261
+ - `cf-turnstile-response`
262
+ - `h-captcha-response`
263
+ - `g-recaptcha-response`
264
+ - `captcha`
265
+
266
+ Attachments (multipart):
42
267
 
43
- When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a
44
- restart.
268
+ - attachment field names must start with `_mm_file` (example: `_mm_file1`, `_mm_file2`)
269
+ - the server enforces `FORM_MAX_ATTACHMENTS` (and `UPLOAD_MAX` per file)
45
270
 
46
- ### Admin UI
271
+ All other non-`_mm_*` fields are treated as user fields and are exposed to templates as `_fields_` (optionally filtered
272
+ by the form template’s `allowed_fields` setting).
47
273
 
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.
274
+ ## Assets
52
275
 
53
- ### Template assets and inline resources
276
+ Templates may reference assets with `asset('path')`:
54
277
 
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.
278
+ - `asset('images/logo.png')` rewrites to a public URL under `ASSET_ROUTE` (default `/asset`)
279
+ - `asset('images/logo.png', true)` embeds as a CID attachment
62
280
 
63
- ## API Overview
281
+ All assets must live under:
64
282
 
65
- | Method | Path | Description |
66
- | ------ | ------------------- | --------------------------------------------- |
67
- | POST | `/v1/tx/template` | Store or update a transactional mail template |
68
- | POST | `/v1/tx/message` | Render and send a stored transactional mail |
69
- | POST | `/v1/form/template` | Store or update a form submission template |
70
- | POST | `/v1/form/message` | Submit a form payload and deliver the email |
283
+ `<CONFIG_PATH>/<domain>/assets/...`
71
284
 
72
- All routes are mounted under `API_BASE_PATH` (default `/api`), so the full path is typically `/api/v1/...`.
285
+ ## Security Notes
73
286
 
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.
287
+ If you expose `POST /api/v1/form/message` publicly, read:
76
288
 
77
- ## Available Scripts
289
+ - `docs/form-security.md` (in this package) for the contract and operational hardening guidance
78
290
 
79
- - `npm run dev` – Start the API server in watch mode
80
- - `npm run build` – Compile TypeScript to the `dist/` directory
81
- - `npm run start` – Launch the compiled server from `dist/`
82
- - `npm run lint` – Lint the project with ESLint
83
- - `npm run format` – Apply ESLint autofixes followed by Prettier formatting
84
- - `npm run cleanbuild` – Clean, lint, format, and rebuild the project
291
+ At a minimum:
85
292
 
86
- ## Repository & Support
293
+ - treat `form_key` as sensitive
294
+ - keep recipient routing server-side (`_mm_recipients` idnames only)
295
+ - set conservative `UPLOAD_MAX` and `FORM_MAX_ATTACHMENTS` if you enable uploads
296
+ - use a real edge rate limiter/WAF in front of the public endpoint
87
297
 
88
- - Repository: https://github.com/technomoron/mail-magic
89
- - Issues: https://github.com/technomoron/mail-magic/issues
298
+ ## Development (Repo)
90
299
 
91
- ## License
300
+ In this repository, `pnpm` is the preferred package manager:
92
301
 
93
- This project is released under the MIT License. See the [LICENSE](LICENSE) file for details.
302
+ ```bash
303
+ pnpm install
304
+ pnpm -w --filter @technomoron/mail-magic dev
305
+ pnpm -w --filter @technomoron/mail-magic test
306
+ pnpm -w --filter @technomoron/mail-magic cleanbuild
307
+ ```
94
308
 
95
- ## Copyright
309
+ Documentation:
96
310
 
97
- Copyright (c) 2025 Bjørn Erik Jacobsen. All rights reserved.
311
+ - `packages/mail-magic/docs/tutorial.md` is a hands-on config walkthrough.
312
+ - `packages/mail-magic/docs/form-security.md` covers the public form endpoint contract and recommended mitigations.
@@ -3,64 +3,17 @@ import path from 'path';
3
3
  import { ApiError, ApiModule } from '@technomoron/api-server-base';
4
4
  import { api_form } from '../models/form.js';
5
5
  import { api_txmail } from '../models/txmail.js';
6
- import { decodeComponent, sendFileAsync } from '../util.js';
6
+ import { SEGMENT_PATTERN, normalizeSubdir } from '../util/paths.js';
7
+ import { moveUploadedFiles } from '../util/uploads.js';
8
+ import { decodeComponent, getBodyValue, sendFileAsync } from '../util.js';
7
9
  import { assert_domain_and_user } from './auth.js';
8
10
  const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
9
- const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
10
11
  export class AssetAPI extends ApiModule {
11
- getBodyValue(body, ...keys) {
12
- for (const key of keys) {
13
- const value = body[key];
14
- if (Array.isArray(value) && value.length > 0) {
15
- return String(value[0]);
16
- }
17
- if (value !== undefined && value !== null) {
18
- return String(value);
19
- }
20
- }
21
- return '';
22
- }
23
- normalizeSubdir(value) {
24
- if (!value) {
25
- return '';
26
- }
27
- const cleaned = value.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
28
- if (!cleaned) {
29
- return '';
30
- }
31
- const segments = cleaned.split('/').filter(Boolean);
32
- for (const segment of segments) {
33
- if (!SEGMENT_PATTERN.test(segment)) {
34
- throw new ApiError({ code: 400, message: `Invalid path segment "${segment}"` });
35
- }
36
- }
37
- return path.join(...segments);
38
- }
39
- async moveUploadedFiles(files, targetDir) {
40
- await fs.promises.mkdir(targetDir, { recursive: true });
41
- for (const file of files) {
42
- const filename = path.basename(file.originalname || '');
43
- if (!filename || !SEGMENT_PATTERN.test(filename)) {
44
- throw new ApiError({ code: 400, message: `Invalid filename "${file.originalname}"` });
45
- }
46
- const destination = path.join(targetDir, filename);
47
- if (destination === file.path) {
48
- continue;
49
- }
50
- try {
51
- await fs.promises.rename(file.path, destination);
52
- }
53
- catch {
54
- await fs.promises.copyFile(file.path, destination);
55
- await fs.promises.unlink(file.path);
56
- }
57
- }
58
- }
59
12
  async resolveTemplateDir(apireq) {
60
13
  const body = apireq.req.body ?? {};
61
- const templateTypeRaw = this.getBodyValue(body, 'templateType', 'template_type', 'type');
62
- const templateName = this.getBodyValue(body, 'template', 'name', 'idname', 'formid');
63
- const locale = this.getBodyValue(body, 'locale');
14
+ const templateTypeRaw = getBodyValue(body, 'templateType', 'template_type', 'type');
15
+ const templateName = getBodyValue(body, 'template', 'name', 'idname', 'formid');
16
+ const locale = getBodyValue(body, 'locale');
64
17
  if (!templateTypeRaw) {
65
18
  throw new ApiError({ code: 400, message: 'Missing templateType for template asset upload' });
66
19
  }
@@ -115,8 +68,8 @@ export class AssetAPI extends ApiModule {
115
68
  throw new ApiError({ code: 400, message: 'No files uploaded' });
116
69
  }
117
70
  const body = apireq.req.body ?? {};
118
- const subdir = this.normalizeSubdir(this.getBodyValue(body, 'path', 'dir'));
119
- const templateType = this.getBodyValue(body, 'templateType', 'template_type', 'type');
71
+ const subdir = normalizeSubdir(getBodyValue(body, 'path', 'dir'));
72
+ const templateType = getBodyValue(body, 'templateType', 'template_type', 'type');
120
73
  let targetRoot;
121
74
  if (templateType) {
122
75
  targetRoot = await this.resolveTemplateDir(apireq);
@@ -129,7 +82,7 @@ export class AssetAPI extends ApiModule {
129
82
  if (candidate !== targetRoot && !candidate.startsWith(normalizedRoot)) {
130
83
  throw new ApiError({ code: 400, message: 'Invalid asset target path' });
131
84
  }
132
- await this.moveUploadedFiles(rawFiles, candidate);
85
+ await moveUploadedFiles(rawFiles, candidate);
133
86
  return [200, { Status: 'OK' }];
134
87
  }
135
88
  defineRoutes() {
package/dist/api/auth.js CHANGED
@@ -1,18 +1,7 @@
1
1
  import { ApiError } from '@technomoron/api-server-base';
2
2
  import { api_domain } from '../models/domain.js';
3
3
  import { api_user } from '../models/user.js';
4
- function getBodyValue(body, ...keys) {
5
- for (const key of keys) {
6
- const value = body[key];
7
- if (Array.isArray(value) && value.length > 0) {
8
- return String(value[0]);
9
- }
10
- if (value !== undefined && value !== null) {
11
- return String(value);
12
- }
13
- }
14
- return '';
15
- }
4
+ import { getBodyValue } from '../util.js';
16
5
  export async function assert_domain_and_user(apireq) {
17
6
  const body = apireq.req.body ?? {};
18
7
  const domain = getBodyValue(body, 'domain');
@@ -0,0 +1,44 @@
1
+ import emailAddresses from 'email-addresses';
2
+ function getFirstStringField(body, key) {
3
+ const value = body[key];
4
+ if (Array.isArray(value) && value.length > 0) {
5
+ return String(value[0] ?? '');
6
+ }
7
+ if (value !== undefined && value !== null) {
8
+ return String(value);
9
+ }
10
+ return '';
11
+ }
12
+ function sanitizeHeaderValue(value, maxLen) {
13
+ const trimmed = String(value ?? '').trim();
14
+ if (!trimmed) {
15
+ return '';
16
+ }
17
+ // Prevent header injection.
18
+ if (/[\r\n]/.test(trimmed)) {
19
+ return '';
20
+ }
21
+ return trimmed.slice(0, maxLen);
22
+ }
23
+ export function extractReplyToFromSubmission(body) {
24
+ const emailRaw = sanitizeHeaderValue(getFirstStringField(body, 'email'), 320);
25
+ if (!emailRaw) {
26
+ return undefined;
27
+ }
28
+ const parsed = emailAddresses.parseOneAddress(emailRaw);
29
+ if (!parsed) {
30
+ return undefined;
31
+ }
32
+ const address = sanitizeHeaderValue(parsed?.address, 320);
33
+ if (!address) {
34
+ return undefined;
35
+ }
36
+ // Prefer a single "name" field, otherwise compose from first_name/last_name.
37
+ let name = sanitizeHeaderValue(getFirstStringField(body, 'name'), 200);
38
+ if (!name) {
39
+ const first = sanitizeHeaderValue(getFirstStringField(body, 'first_name'), 100);
40
+ const last = sanitizeHeaderValue(getFirstStringField(body, 'last_name'), 100);
41
+ name = sanitizeHeaderValue(`${first}${first && last ? ' ' : ''}${last}`, 200);
42
+ }
43
+ return name ? { name, address } : address;
44
+ }