@technomoron/mail-magic 1.0.32 → 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 (40) hide show
  1. package/CHANGES +10 -0
  2. package/README.md +213 -122
  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 -18
  11. package/dist/models/db.js +5 -5
  12. package/dist/models/domain.js +16 -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/store.js +53 -22
  20. package/dist/swagger.js +107 -0
  21. package/dist/util/captcha.js +24 -0
  22. package/dist/util/email.js +19 -0
  23. package/dist/util/paths.js +41 -0
  24. package/dist/util/ratelimit.js +48 -0
  25. package/dist/util/uploads.js +48 -0
  26. package/dist/util/utils.js +151 -0
  27. package/dist/util.js +4 -127
  28. package/docs/config-example/example.test/assets/files/banner.png +1 -0
  29. package/docs/config-example/example.test/assets/images/logo.png +1 -0
  30. package/docs/config-example/example.test/form-template/base.njk +6 -0
  31. package/docs/config-example/example.test/form-template/contact.njk +9 -0
  32. package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
  33. package/docs/config-example/example.test/tx-template/base.njk +10 -0
  34. package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
  35. package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
  36. package/docs/config-example/init-data.json +57 -0
  37. package/docs/form-security.md +194 -0
  38. package/docs/swagger/openapi.json +1321 -0
  39. package/{TUTORIAL.MD → docs/tutorial.md} +24 -15
  40. package/package.json +3 -3
package/CHANGES CHANGED
@@ -1,6 +1,16 @@
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
+
1
7
  Version 1.0.32 (2026-02-08)
2
8
 
3
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.
4
14
 
5
15
  Version 1.0.31 (2026-02-08)
6
16
 
package/README.md CHANGED
@@ -1,18 +1,36 @@
1
1
  # @technomoron/mail-magic
2
2
 
3
- Mail Magic is a TypeScript service for managing, templating, and delivering transactional emails and public form
4
- submissions. It exposes a small REST API built on `@technomoron/api-server-base`, persists data with Sequelize/SQLite
5
- (by default), and renders outbound messages with Nunjucks templates.
6
-
7
- ## Features
8
-
9
- - Store and send transactional templates through a JSON API
10
- - Store and deliver form submission templates through a public endpoint
11
- - Optional recipient allowlist so public forms can target a named recipient without exposing email addresses
12
- - Optional anti-abuse controls for public forms (rate limiting, attachment limits, CAPTCHA)
13
- - Preprocess templates (includes + `asset(...)` rewrites) with `@technomoron/unyuck`
14
- - Nodemailer transport configuration driven by environment variables
15
- - Optional bundled admin UI (placeholder) served at `/` when enabled
3
+ Mail Magic is a small TypeScript HTTP service that:
4
+
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
+
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.
11
+
12
+ ## Contents
13
+
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
+
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)
16
34
 
17
35
  ## Install
18
36
 
@@ -20,21 +38,16 @@ submissions. It exposes a small REST API built on `@technomoron/api-server-base`
20
38
  npm install @technomoron/mail-magic
21
39
  ```
22
40
 
23
- The package ships a `mail-magic` CLI.
24
-
25
- ## Run
26
-
27
- Mail Magic loads:
41
+ The package ships a `mail-magic` CLI that loads a `.env` file and starts the server.
28
42
 
29
- - **Environment variables** (the `mail-magic` CLI supports `.env`)
30
- - **Config directory** (`CONFIG_PATH`) containing `init-data.json`, templates, and assets
43
+ ## Quick Start
31
44
 
32
- ### 1. Create `.env`
45
+ ### 1. Create a `.env`
33
46
 
34
- Copy `.env-dist` and fill in the required bits:
47
+ Start from the repo’s `.env-dist` and set at least:
35
48
 
36
49
  ```ini
37
- # Required: used to HMAC API tokens before DB lookup (keep it stable).
50
+ # REQUIRED. Keep stable: used to HMAC API tokens before DB lookup.
38
51
  API_TOKEN_PEPPER=change-me-please-use-a-long-random-string
39
52
 
40
53
  CONFIG_PATH=./data
@@ -50,9 +63,9 @@ SMTP_SECURE=false
50
63
  SMTP_TLS_REJECT=false
51
64
  ```
52
65
 
53
- ### 2. Create a config directory
66
+ ### 2. Create a minimal config directory
54
67
 
55
- Minimum layout:
68
+ `CONFIG_PATH` points at a directory containing `init-data.json` plus per-domain subfolders:
56
69
 
57
70
  ```text
58
71
  data/
@@ -60,19 +73,15 @@ data/
60
73
  example.test/
61
74
  assets/
62
75
  images/logo.png
63
- files/banner.png
64
76
  tx-template/
65
77
  welcome.njk
66
- partials/
67
- header.njk
68
78
  form-template/
69
79
  contact.njk
70
- partials/
71
- fields.njk
72
80
  ```
73
81
 
74
- Important: assets referenced via `asset('...')` must live under `<CONFIG_PATH>/<domain>/assets` (not under
75
- `tx-template/` or `form-template/`).
82
+ Assets referenced via `asset('...')` must live under:
83
+
84
+ `<CONFIG_PATH>/<domain>/assets/...`
76
85
 
77
86
  ### 3. Start the server
78
87
 
@@ -80,142 +89,224 @@ Important: assets referenced via `asset('...')` must live under `<CONFIG_PATH>/<
80
89
  mail-magic --env ./.env
81
90
  ```
82
91
 
83
- In this repository, development is optimized for `pnpm`:
92
+ ## Concepts
84
93
 
85
- ```bash
86
- pnpm install
87
- pnpm -w --filter @technomoron/mail-magic dev
88
- ```
94
+ ### Domain-first config layout
89
95
 
90
- ## Authentication (API Tokens)
96
+ Mail Magic treats each domain as a root for templates and assets on disk:
91
97
 
92
- Authenticated endpoints require:
98
+ - Transactional templates: `<CONFIG_PATH>/<domain>/tx-template/...`
99
+ - Form templates: `<CONFIG_PATH>/<domain>/form-template/...`
100
+ - Public assets: `<CONFIG_PATH>/<domain>/assets/...`
93
101
 
94
- ```text
95
- Authorization: Bearer apikey-<user_token>
96
- ```
102
+ ### Transactional vs form messages
97
103
 
98
- Tokens are stored as `HMAC-SHA256(token, API_TOKEN_PEPPER)` in the database. You can seed a plaintext `token` in
99
- `init-data.json`; it will be HMACed on import and the plaintext cleared.
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`
100
112
 
101
- ## API Overview
113
+ ### `form_key` (public identifier)
102
114
 
103
- All routes are mounted under `API_BASE_PATH` (default `/api`), so the full path is typically `/api/v1/...`.
115
+ `POST /api/v1/form/template` returns a stable random `form_key`. Public submissions use that key as `_mm_form_key`.
104
116
 
105
- | Auth | Method | Path | Description |
106
- | ---- | ------ | -------------------- | ------------------------------------------------------- |
107
- | No | GET | `/v1/ping` | Health check |
108
- | Yes | POST | `/v1/tx/template` | Store/update a transactional template |
109
- | Yes | POST | `/v1/tx/message` | Render + deliver a stored transactional message |
110
- | Yes | POST | `/v1/form/template` | Store/update a form template (returns `form_key`) |
111
- | Yes | POST | `/v1/form/recipient` | Upsert a recipient mapping (domain-wide or form-scoped) |
112
- | No | POST | `/v1/form/message` | Public form submission endpoint |
113
- | Yes | POST | `/v1/assets` | Upload domain or template-scoped assets |
117
+ Treat `form_key` as sensitive: anyone who has it can submit to that form.
114
118
 
115
- Public assets are served from `ASSET_ROUTE` (default `/asset`). When `API_BASE_PATH` is in use, assets are also
116
- reachable under `/api/asset/...` to match older `API_URL` defaults.
119
+ ### Recipient allowlist
117
120
 
118
- ## Public Forms (`form_key`) and Recipient Allowlist
121
+ For “choose a recipient” forms, do not accept user-supplied emails. Instead:
119
122
 
120
- ### Prefer `form_key` for public submissions
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.
121
125
 
122
- `POST /api/v1/form/template` returns a stable random ID (`data.form_key`, generated via nanoid). Public form submissions
123
- should use `form_key` instead of `domain + formid`, because `domain + formid` can be ambiguous across locales or
124
- multi-tenant setups.
126
+ The server resolves those `idname`s to real email addresses using the allowlist.
125
127
 
126
- ### Public recipient selection without exposing email addresses
128
+ ## Configuration
127
129
 
128
- For cases like "contact a journalist", you can configure named recipients (allowlist) and let the public client select
129
- by `recipient_idname`:
130
+ Mail Magic is configured via environment variables plus the `CONFIG_PATH` directory.
130
131
 
131
- 1. Upsert recipient mapping (authenticated): `POST /api/v1/form/recipient` `{ domain, form_key?, idname, email, name? }`
132
- 2. Submit the public form (no auth): `POST /api/v1/form/message` `{ form_key, recipient_idname, ...fields }`
132
+ The full set of environment variables is documented in the repository’s `.env-dist`.
133
133
 
134
- Mappings are scoped by `(domain_id, form_key, idname)`:
134
+ Commonly used variables:
135
135
 
136
- - Use `form_key` to create a per-form allowlist.
137
- - Omit `form_key` to create a domain-wide default allowlist.
138
- - Form-scoped mappings override domain-wide mappings for the same `idname`.
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`
139
149
 
140
- ### Overriding `recipient` is secret-gated
150
+ ## Swagger / OpenAPI
141
151
 
142
- The `recipient` field on the public endpoint is accepted only when the form has a `secret` configured. This prevents
143
- turning `/v1/form/message` into an open relay.
152
+ Mail Magic ships an OpenAPI JSON spec and can expose it at runtime.
144
153
 
145
- ## Anti-Abuse Controls (Public Form Endpoint)
154
+ Packaged spec (on disk):
146
155
 
147
- All knobs below are optional and default to "off" unless stated otherwise:
156
+ - `node_modules/@technomoron/mail-magic/docs/swagger/openapi.json`
148
157
 
149
- - Upload size limit per file: `UPLOAD_MAX` (bytes, enforced by the API server)
150
- - Attachment count limit: `FORM_MAX_ATTACHMENTS` (`-1` unlimited, `0` disables attachments)
151
- - Rate limiting: `FORM_RATE_LIMIT_WINDOW_SEC` + `FORM_RATE_LIMIT_MAX` (fixed window, in-memory, per client IP)
152
- - Upload cleanup: `FORM_KEEP_UPLOADS` (when `false`, uploaded files are deleted after processing, even on failure)
158
+ Runtime spec endpoint:
153
159
 
154
- ### CAPTCHA (optional)
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
155
163
 
156
- CAPTCHA verification is enabled when `FORM_CAPTCHA_SECRET` is set. You can require tokens globally with
157
- `FORM_CAPTCHA_REQUIRED=true`, or per form with `captcha_required=true` on `POST /api/v1/form/template`.
164
+ This spec is the canonical reference for:
158
165
 
159
- Supported providers (`FORM_CAPTCHA_PROVIDER`):
166
+ - the exact route list
167
+ - request/response bodies
168
+ - status codes and error shapes
160
169
 
161
- - `turnstile` (Cloudflare)
162
- - `hcaptcha`
163
- - `recaptcha` (Google)
170
+ ## API Usage (Examples)
164
171
 
165
- Token field names accepted by the server:
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):
166
260
 
167
261
  - `cf-turnstile-response`
168
262
  - `h-captcha-response`
169
263
  - `g-recaptcha-response`
170
- - `captcha` (generic)
171
-
172
- ## Template Rendering Notes
264
+ - `captcha`
173
265
 
174
- ### Autoescape (`AUTOESCAPE_HTML`)
266
+ Attachments (multipart):
175
267
 
176
- Nunjucks HTML autoescape is enabled by default. You can toggle it via `AUTOESCAPE_HTML` (default: `true`).
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)
177
270
 
178
- Nunjucks also supports the `|safe` filter. Use it only for trusted content.
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).
179
273
 
180
- ### Context Variables
274
+ ## Assets
181
275
 
182
- Transactional templates receive:
276
+ Templates may reference assets with `asset('path')`:
183
277
 
184
- - `_rcpt_email_`: current recipient
185
- - `_vars_`: the `vars` object
186
- - `_attachments_`: multipart field-name to filename map for uploaded attachments
187
- - `_meta_`: request metadata (`client_ip`, `ip_chain`, `received_at`)
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
188
280
 
189
- Form templates receive:
281
+ All assets must live under:
190
282
 
191
- - `_fields_`: all submitted fields (including `vars`, `formid`, etc)
192
- - `_files_`: uploaded files
193
- - `_attachments_`, `_vars_`, `_meta_`
194
- - `_rcpt_email_`, `_rcpt_name_`, `_rcpt_idname_` (when using `recipient_idname`)
283
+ `<CONFIG_PATH>/<domain>/assets/...`
195
284
 
196
- ### Assets (`asset('...')`)
285
+ ## Security Notes
197
286
 
198
- In HTML templates, write:
287
+ If you expose `POST /api/v1/form/message` publicly, read:
199
288
 
200
- - `asset('images/logo.png', true)` to embed as a CID attachment (`cid:images/logo.png`)
201
- - `asset('files/banner.png')` to rewrite to a public URL under `ASSET_ROUTE`
289
+ - `docs/form-security.md` (in this package) for the contract and operational hardening guidance
202
290
 
203
- Files must exist under `<CONFIG_PATH>/<domain>/assets`.
291
+ At a minimum:
204
292
 
205
- ## Database Notes
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
206
297
 
207
- - `DB_AUTO_RELOAD` watches `init-data.json` and refreshes templates/forms without a restart (development).
208
- - `DB_FORCE_SYNC` drops and recreates tables on startup (dangerous).
209
- - `DB_SYNC_ALTER` controls Sequelize `sync({ alter: ... })` on startup.
298
+ ## Development (Repo)
210
299
 
211
- ## Admin UI / Swagger
300
+ In this repository, `pnpm` is the preferred package manager:
212
301
 
213
- - `ADMIN_ENABLED=true` enables the optional admin UI and admin API module (if `@technomoron/mail-magic-admin` is
214
- installed).
215
- - `SWAGGER_ENABLED=true` exposes Swagger/OpenAPI (path defaults to `/api/swagger`).
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
+ ```
216
308
 
217
- ## Available Scripts (Repository)
309
+ Documentation:
218
310
 
219
- - `pnpm -w --filter @technomoron/mail-magic dev` - start watch mode via `nodemon`
220
- - `pnpm -w --filter @technomoron/mail-magic test` - run server tests
221
- - `pnpm -w --filter @technomoron/mail-magic cleanbuild` - format + build
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
+ }