@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.
- package/CHANGES +50 -0
- package/README.md +285 -70
- package/dist/api/assets.js +9 -56
- package/dist/api/auth.js +1 -12
- package/dist/api/form-replyto.js +44 -0
- package/dist/api/form-submission.js +95 -0
- package/dist/api/forms.js +262 -318
- package/dist/api/mailer.js +1 -1
- package/dist/bin/mail-magic.js +2 -2
- package/dist/index.js +30 -19
- package/dist/models/db.js +5 -4
- package/dist/models/domain.js +17 -8
- package/dist/models/form.js +110 -38
- package/dist/models/init.js +34 -74
- package/dist/models/recipient.js +12 -8
- package/dist/models/txmail.js +22 -25
- package/dist/models/user.js +14 -10
- package/dist/server.js +1 -1
- package/dist/store/envloader.js +9 -4
- package/dist/store/store.js +53 -22
- package/dist/swagger.js +107 -0
- package/dist/util/captcha.js +24 -0
- package/dist/util/email.js +19 -0
- package/dist/util/paths.js +41 -0
- package/dist/util/ratelimit.js +48 -0
- package/dist/util/uploads.js +48 -0
- package/dist/util/utils.js +151 -0
- package/dist/util.js +4 -127
- package/docs/config-example/example.test/assets/files/banner.png +1 -0
- package/docs/config-example/example.test/assets/images/logo.png +1 -0
- package/docs/config-example/example.test/form-template/base.njk +6 -0
- package/docs/config-example/example.test/form-template/contact.njk +9 -0
- package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
- package/docs/config-example/example.test/tx-template/base.njk +10 -0
- package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
- package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
- package/docs/config-example/init-data.json +57 -0
- package/docs/form-security.md +194 -0
- package/docs/swagger/openapi.json +1321 -0
- package/{TUTORIAL.MD → docs/tutorial.md} +127 -33
- 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
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
##
|
|
12
|
+
## Contents
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
+
Templates may reference assets with `asset('path')`:
|
|
54
277
|
|
|
55
|
-
-
|
|
56
|
-
|
|
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
|
-
|
|
281
|
+
All assets must live under:
|
|
64
282
|
|
|
65
|
-
|
|
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
|
-
|
|
285
|
+
## Security Notes
|
|
73
286
|
|
|
74
|
-
|
|
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
|
-
|
|
289
|
+
- `docs/form-security.md` (in this package) for the contract and operational hardening guidance
|
|
78
290
|
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
- Issues: https://github.com/technomoron/mail-magic/issues
|
|
298
|
+
## Development (Repo)
|
|
90
299
|
|
|
91
|
-
|
|
300
|
+
In this repository, `pnpm` is the preferred package manager:
|
|
92
301
|
|
|
93
|
-
|
|
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
|
-
|
|
309
|
+
Documentation:
|
|
96
310
|
|
|
97
|
-
|
|
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.
|
package/dist/api/assets.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
62
|
-
const templateName =
|
|
63
|
-
const 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 =
|
|
119
|
-
const templateType =
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|