@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.
- package/CHANGES +10 -0
- package/README.md +213 -122
- 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 -18
- package/dist/models/db.js +5 -5
- package/dist/models/domain.js +16 -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/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} +24 -15
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
Start from the repo’s `.env-dist` and set at least:
|
|
35
48
|
|
|
36
49
|
```ini
|
|
37
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
92
|
+
## Concepts
|
|
84
93
|
|
|
85
|
-
|
|
86
|
-
pnpm install
|
|
87
|
-
pnpm -w --filter @technomoron/mail-magic dev
|
|
88
|
-
```
|
|
94
|
+
### Domain-first config layout
|
|
89
95
|
|
|
90
|
-
|
|
96
|
+
Mail Magic treats each domain as a root for templates and assets on disk:
|
|
91
97
|
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
Authorization: Bearer apikey-<user_token>
|
|
96
|
-
```
|
|
102
|
+
### Transactional vs form messages
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
113
|
+
### `form_key` (public identifier)
|
|
102
114
|
|
|
103
|
-
|
|
115
|
+
`POST /api/v1/form/template` returns a stable random `form_key`. Public submissions use that key as `_mm_form_key`.
|
|
104
116
|
|
|
105
|
-
|
|
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
|
-
|
|
116
|
-
reachable under `/api/asset/...` to match older `API_URL` defaults.
|
|
119
|
+
### Recipient allowlist
|
|
117
120
|
|
|
118
|
-
|
|
121
|
+
For “choose a recipient” forms, do not accept user-supplied emails. Instead:
|
|
119
122
|
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
+
## Configuration
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
by `recipient_idname`:
|
|
130
|
+
Mail Magic is configured via environment variables plus the `CONFIG_PATH` directory.
|
|
130
131
|
|
|
131
|
-
|
|
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
|
-
|
|
134
|
+
Commonly used variables:
|
|
135
135
|
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
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
|
-
|
|
150
|
+
## Swagger / OpenAPI
|
|
141
151
|
|
|
142
|
-
|
|
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
|
-
|
|
154
|
+
Packaged spec (on disk):
|
|
146
155
|
|
|
147
|
-
|
|
156
|
+
- `node_modules/@technomoron/mail-magic/docs/swagger/openapi.json`
|
|
148
157
|
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
- the exact route list
|
|
167
|
+
- request/response bodies
|
|
168
|
+
- status codes and error shapes
|
|
160
169
|
|
|
161
|
-
|
|
162
|
-
- `hcaptcha`
|
|
163
|
-
- `recaptcha` (Google)
|
|
170
|
+
## API Usage (Examples)
|
|
164
171
|
|
|
165
|
-
|
|
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`
|
|
171
|
-
|
|
172
|
-
## Template Rendering Notes
|
|
264
|
+
- `captcha`
|
|
173
265
|
|
|
174
|
-
|
|
266
|
+
Attachments (multipart):
|
|
175
267
|
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
+
## Assets
|
|
181
275
|
|
|
182
|
-
|
|
276
|
+
Templates may reference assets with `asset('path')`:
|
|
183
277
|
|
|
184
|
-
- `
|
|
185
|
-
- `
|
|
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
|
-
|
|
281
|
+
All assets must live under:
|
|
190
282
|
|
|
191
|
-
|
|
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
|
-
|
|
285
|
+
## Security Notes
|
|
197
286
|
|
|
198
|
-
|
|
287
|
+
If you expose `POST /api/v1/form/message` publicly, read:
|
|
199
288
|
|
|
200
|
-
- `
|
|
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
|
-
|
|
291
|
+
At a minimum:
|
|
204
292
|
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
+
In this repository, `pnpm` is the preferred package manager:
|
|
212
301
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
-
|
|
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
|
-
|
|
309
|
+
Documentation:
|
|
218
310
|
|
|
219
|
-
- `
|
|
220
|
-
- `
|
|
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.
|
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
|
+
}
|