@technomoron/mail-magic 1.0.23 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGES CHANGED
@@ -1,3 +1,43 @@
1
+ Version 1.0.32 (2026-02-08)
2
+
3
+ - Add regression coverage for legacy plaintext API token migration to `token_hmac`.
4
+
5
+ Version 1.0.31 (2026-02-08)
6
+
7
+ - Add regression coverage for form lookup ambiguity handling and secret-gated recipient overrides.
8
+
9
+ Version 1.0.30 (2026-02-08)
10
+
11
+ - Add regression coverage for Nunjucks HTML autoescape behavior and the `|safe` filter.
12
+
13
+ Version 1.0.29 (2026-02-08)
14
+
15
+ - Add regression coverage for CAPTCHA verification flows on the public form endpoint.
16
+
17
+ Version 1.0.28 (2026-02-08)
18
+
19
+ - Add regression coverage for public form anti-abuse controls (rate limiting, attachment limits, and upload cleanup).
20
+
21
+ Version 1.0.27 (2026-02-08)
22
+
23
+ - Refresh README/TUTORIAL with current API routes, config layout, and security-related configuration (API token pepper,
24
+ form_key, recipient allowlist, and form anti-abuse options).
25
+ - Update `config-example/` to match the current init-data schema and domain-rooted config directory layout.
26
+
27
+ Version 1.0.26 (2026-02-07)
28
+
29
+ - Fix env/docs polish and remove unused code flagged by ESLint.
30
+
31
+ Version 1.0.25 (2026-02-07)
32
+
33
+ - Default `DB_AUTO_RELOAD` to false to avoid unexpected config reloads in production.
34
+ - Added `DB_SYNC_ALTER` to control Sequelize `sync({ alter: ... })` on startup.
35
+
36
+ Version 1.0.24 (2026-02-07)
37
+
38
+ - Validate imported domain names against a safe pattern to prevent config path
39
+ traversal via malformed domain identifiers.
40
+
1
41
  Version 1.0.23 (2026-02-07)
2
42
 
3
43
  - Added a form recipient allowlist table plus `POST /api/v1/form/recipient` to map a
package/README.md CHANGED
@@ -1,97 +1,221 @@
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 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
6
 
7
7
  ## Features
8
8
 
9
- - Upload, store, and send templated email content through a JSON API
10
- - Preprocess template assets with `@technomoron/unyuck` before persisting
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`
11
14
  - 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 `/`
15
-
16
- ## Getting Started
17
-
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`
25
-
26
- During development you can run `npm run dev` for a watch mode that recompiles on change and restarts via `nodemon`.
27
-
28
- ## Configuration
29
-
30
- - **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API
31
- host/port, the config directory path, database options, and `ADMIN_ENABLED`/`ADMIN_APP_PATH` to control the admin
32
- UI/API.
33
- - **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template
34
- assets. Each domain now lives directly under the config root (for example `data/example.com/form-template/…`). Use an
35
- absolute path or a relative one like `../data` when you want the config outside the repo. Review `config-example/` for
36
- the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks
37
- templates.
38
- - **Database** defaults to SQLite (`maildata.db`). You can switch dialects by updating the environment options if your
39
- deployment requires another database.
40
- - **Uploads** default to `<CONFIG_PATH>/<domain>/uploads` via `UPLOAD_PATH=./{domain}/uploads`. Set a fixed path if you
41
- prefer a shared upload directory.
42
-
43
- When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a
44
- restart.
45
-
46
- ### Admin UI
47
-
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.
52
-
53
- ### Template assets and inline resources
54
-
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.
15
+ - Optional bundled admin UI (placeholder) served at `/` when enabled
62
16
 
63
- ## API Overview
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install @technomoron/mail-magic
21
+ ```
22
+
23
+ The package ships a `mail-magic` CLI.
24
+
25
+ ## Run
26
+
27
+ Mail Magic loads:
28
+
29
+ - **Environment variables** (the `mail-magic` CLI supports `.env`)
30
+ - **Config directory** (`CONFIG_PATH`) containing `init-data.json`, templates, and assets
31
+
32
+ ### 1. Create `.env`
33
+
34
+ Copy `.env-dist` and fill in the required bits:
35
+
36
+ ```ini
37
+ # Required: used to HMAC API tokens before DB lookup (keep it stable).
38
+ API_TOKEN_PEPPER=change-me-please-use-a-long-random-string
39
+
40
+ CONFIG_PATH=./data
41
+
42
+ API_HOST=127.0.0.1
43
+ API_PORT=3776
44
+ API_BASE_PATH=/api
45
+ API_URL=http://127.0.0.1:3776
46
+
47
+ SMTP_HOST=127.0.0.1
48
+ SMTP_PORT=1025
49
+ SMTP_SECURE=false
50
+ SMTP_TLS_REJECT=false
51
+ ```
52
+
53
+ ### 2. Create a config directory
54
+
55
+ Minimum layout:
64
56
 
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 |
57
+ ```text
58
+ data/
59
+ init-data.json
60
+ example.test/
61
+ assets/
62
+ images/logo.png
63
+ files/banner.png
64
+ tx-template/
65
+ welcome.njk
66
+ partials/
67
+ header.njk
68
+ form-template/
69
+ contact.njk
70
+ partials/
71
+ fields.njk
72
+ ```
73
+
74
+ Important: assets referenced via `asset('...')` must live under `<CONFIG_PATH>/<domain>/assets` (not under
75
+ `tx-template/` or `form-template/`).
76
+
77
+ ### 3. Start the server
78
+
79
+ ```bash
80
+ mail-magic --env ./.env
81
+ ```
82
+
83
+ In this repository, development is optimized for `pnpm`:
84
+
85
+ ```bash
86
+ pnpm install
87
+ pnpm -w --filter @technomoron/mail-magic dev
88
+ ```
89
+
90
+ ## Authentication (API Tokens)
91
+
92
+ Authenticated endpoints require:
93
+
94
+ ```text
95
+ Authorization: Bearer apikey-<user_token>
96
+ ```
97
+
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.
100
+
101
+ ## API Overview
71
102
 
72
103
  All routes are mounted under `API_BASE_PATH` (default `/api`), so the full path is typically `/api/v1/...`.
73
104
 
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.
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 |
114
+
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.
117
+
118
+ ## Public Forms (`form_key`) and Recipient Allowlist
119
+
120
+ ### Prefer `form_key` for public submissions
121
+
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.
125
+
126
+ ### Public recipient selection without exposing email addresses
127
+
128
+ For cases like "contact a journalist", you can configure named recipients (allowlist) and let the public client select
129
+ by `recipient_idname`:
130
+
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 }`
133
+
134
+ Mappings are scoped by `(domain_id, form_key, idname)`:
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`.
139
+
140
+ ### Overriding `recipient` is secret-gated
141
+
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.
144
+
145
+ ## Anti-Abuse Controls (Public Form Endpoint)
146
+
147
+ All knobs below are optional and default to "off" unless stated otherwise:
148
+
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)
153
+
154
+ ### CAPTCHA (optional)
155
+
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`.
158
+
159
+ Supported providers (`FORM_CAPTCHA_PROVIDER`):
160
+
161
+ - `turnstile` (Cloudflare)
162
+ - `hcaptcha`
163
+ - `recaptcha` (Google)
164
+
165
+ Token field names accepted by the server:
166
+
167
+ - `cf-turnstile-response`
168
+ - `h-captcha-response`
169
+ - `g-recaptcha-response`
170
+ - `captcha` (generic)
171
+
172
+ ## Template Rendering Notes
173
+
174
+ ### Autoescape (`AUTOESCAPE_HTML`)
175
+
176
+ Nunjucks HTML autoescape is enabled by default. You can toggle it via `AUTOESCAPE_HTML` (default: `true`).
177
+
178
+ Nunjucks also supports the `|safe` filter. Use it only for trusted content.
179
+
180
+ ### Context Variables
181
+
182
+ Transactional templates receive:
183
+
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`)
188
+
189
+ Form templates receive:
190
+
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`)
195
+
196
+ ### Assets (`asset('...')`)
197
+
198
+ In HTML templates, write:
76
199
 
77
- ## Available Scripts
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`
78
202
 
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
203
+ Files must exist under `<CONFIG_PATH>/<domain>/assets`.
85
204
 
86
- ## Repository & Support
205
+ ## Database Notes
87
206
 
88
- - Repository: https://github.com/technomoron/mail-magic
89
- - Issues: https://github.com/technomoron/mail-magic/issues
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.
90
210
 
91
- ## License
211
+ ## Admin UI / Swagger
92
212
 
93
- This project is released under the MIT License. See the [LICENSE](LICENSE) file for details.
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`).
94
216
 
95
- ## Copyright
217
+ ## Available Scripts (Repository)
96
218
 
97
- Copyright (c) 2025 Bjørn Erik Jacobsen. All rights reserved.
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
package/TUTORIAL.MD CHANGED
@@ -17,8 +17,9 @@ export CONFIG_ROOT=$(realpath ../myorg-config)
17
17
  Update your `.env` (or runtime environment) to point at the new workspace:
18
18
 
19
19
  ```
20
+ API_TOKEN_PEPPER=<generate-a-long-random-string>
20
21
  CONFIG_PATH=${CONFIG_ROOT}
21
- DB_AUTO_RELOAD=1 # optional: hotreload init-data and templates
22
+ DB_AUTO_RELOAD=1 # optional: hot-reload init-data and templates
22
23
  UPLOAD_PATH=./{domain}/uploads
23
24
  ```
24
25
 
@@ -30,9 +31,9 @@ From now on the tutorial assumes `${CONFIG_ROOT}` is the root of the custom conf
30
31
 
31
32
  ```bash
32
33
  mkdir -p \
33
- "$CONFIG_ROOT"/myorg.com/{form-template,tx-template} \
34
- "$CONFIG_ROOT"/myorg.com/form-template/{partials,assets} \
35
- "$CONFIG_ROOT"/myorg.com/tx-template/{partials,assets}
34
+ "$CONFIG_ROOT"/myorg.com/assets \
35
+ "$CONFIG_ROOT"/myorg.com/form-template/partials \
36
+ "$CONFIG_ROOT"/myorg.com/tx-template/partials
36
37
  ```
37
38
 
38
39
  The resulting tree should look like this (logo placement shown for clarity — add the file in step 4):
@@ -41,15 +42,14 @@ The resulting tree should look like this (logo placement shown for clarity — a
41
42
  myorg-config/
42
43
  ├── init-data.json
43
44
  └── myorg.com/
45
+ ├── assets/
46
+ │ └── logo.png
44
47
  ├── form-template/
45
- │ ├── assets/
46
48
  │ ├── contact.njk
47
49
  │ └── partials/
48
50
  │ ├── footer.njk
49
51
  │ └── header.njk
50
52
  └── tx-template/
51
- ├── assets/
52
- │ └── logo.png
53
53
  ├── partials/
54
54
  │ ├── footer.njk
55
55
  │ └── header.njk
@@ -58,7 +58,7 @@ myorg-config/
58
58
 
59
59
  > **Tip:** If you want to share partials between templates, keep file names aligned (e.g. identical `header.njk` content under both `form-template/partials/` and `tx-template/partials/`).
60
60
 
61
- > **Assets vs inline:** Any file you want to serve as an external URL must live under `myorg.com/assets/`. The template helper `asset('logo.png')` will become `http://localhost:3776/asset/myorg.com/logo.png` by default. You can change the base via `ASSET_PUBLIC_BASE` (or `API_URL`) and the path via `ASSET_ROUTE`. Use `asset('logo.png', true)` when you need the file embedded as a CID attachment instead.
61
+ > **Assets vs inline:** Any file referenced via `asset('...')` must live under `myorg.com/assets/`. The helper `asset('logo.png')` will become `http://localhost:3776/asset/myorg.com/logo.png` by default. You can change the base via `ASSET_PUBLIC_BASE` (or `API_URL`) and the route via `ASSET_ROUTE`. Use `asset('logo.png', true)` when you need the file embedded as a CID attachment instead.
62
62
 
63
63
  ---
64
64
 
@@ -74,7 +74,9 @@ Create `${CONFIG_ROOT}/init-data.json` so the service can bootstrap the MyOrg us
74
74
  "idname": "myorg",
75
75
  "token": "<generate-a-32-char-hex-token>",
76
76
  "name": "MyOrg",
77
- "email": "notifications@myorg.com"
77
+ "email": "notifications@myorg.com",
78
+ "domain": 10,
79
+ "locale": "en"
78
80
  }
79
81
  ],
80
82
  "domain": [
@@ -82,7 +84,9 @@ Create `${CONFIG_ROOT}/init-data.json` so the service can bootstrap the MyOrg us
82
84
  "domain_id": 10,
83
85
  "user_id": 10,
84
86
  "name": "myorg.com",
85
- "sender": "MyOrg Mailer <noreply@myorg.com>"
87
+ "sender": "MyOrg Mailer <noreply@myorg.com>",
88
+ "locale": "en",
89
+ "is_default": true
86
90
  }
87
91
  ],
88
92
  "template": [
@@ -91,30 +95,36 @@ Create `${CONFIG_ROOT}/init-data.json` so the service can bootstrap the MyOrg us
91
95
  "user_id": 10,
92
96
  "domain_id": 10,
93
97
  "name": "welcome",
94
- "slug": "welcome",
95
- "locale": "",
96
- "filename": "welcome.njk",
98
+ "locale": "en",
99
+ "filename": "",
97
100
  "sender": "support@myorg.com",
98
101
  "subject": "Welcome to MyOrg",
99
- "template": ""
102
+ "template": "",
103
+ "slug": ""
100
104
  }
101
105
  ],
102
106
  "form": [
103
107
  {
104
108
  "form_id": 100,
109
+ "form_key": "<generate-a-random-form-key>",
105
110
  "user_id": 10,
106
111
  "domain_id": 10,
112
+ "locale": "en",
107
113
  "idname": "contact",
108
- "filename": "contact.njk",
114
+ "filename": "",
109
115
  "sender": "MyOrg Support <support@myorg.com>",
110
116
  "recipient": "contact@myorg.com",
111
- "subject": "New contact form submission"
117
+ "subject": "New contact form submission",
118
+ "secret": "s3cret",
119
+ "slug": ""
112
120
  }
113
121
  ]
114
122
  }
115
123
  ```
116
124
 
117
125
  - Generate the API token with `openssl rand -hex 16` (or any 32-character hex string).
126
+ - `API_TOKEN_PEPPER` must be set when starting the server. Tokens are stored as `HMAC-SHA256(token, API_TOKEN_PEPPER)`
127
+ in the database, so the plaintext `token` is cleared after import.
118
128
  - Leave `template` empty; Mail Magic will populate it with the flattened HTML the first time it processes the files.
119
129
  - Set `DB_AUTO_RELOAD=1` (see step 1) if you want the service to re-import whenever `init-data.json` changes.
120
130
 
@@ -261,11 +271,10 @@ Create `${CONFIG_ROOT}/init-data.json` so the service can bootstrap the MyOrg us
261
271
 
262
272
  ### 4.5 Provide the logo asset
263
273
 
264
- Copy or design a square PNG logo and add it to both asset folders so the inline references resolve (Mail Magic resolves assets per template type):
274
+ Copy or design a square PNG logo and add it under the domain assets folder so the inline references resolve:
265
275
 
266
276
  ```bash
267
- cp path/to/myorg-logo.png "$CONFIG_ROOT"/myorg.com/tx-template/assets/logo.png
268
- cp path/to/myorg-logo.png "$CONFIG_ROOT"/myorg.com/form-template/assets/logo.png
277
+ cp path/to/myorg-logo.png "$CONFIG_ROOT"/myorg.com/assets/logo.png
269
278
  ```
270
279
 
271
280
  The inline flag (`true`) in `asset('logo.png', true)` tells Mail Magic to attach the image and rewrite the markup to `cid:logo.png` when messages are flattened.
@@ -274,21 +283,97 @@ The inline flag (`true`) in `asset('logo.png', true)` tells Mail Magic to attach
274
283
 
275
284
  ## 5. Start Mail Magic and verify
276
285
 
277
- 1. Restart `mail-magic` (or run `npm run dev`) so it picks up the new `CONFIG_PATH`.
286
+ 1. Restart `mail-magic` (or run `pnpm -w --filter @technomoron/mail-magic dev`) so it picks up the new `CONFIG_PATH`.
278
287
  2. Confirm the bootstrap worked — the logs should mention importing user `myorg` and domain `myorg.com`.
279
- 3. Trigger a transactional email:
288
+ 3. Verify the server is reachable:
280
289
  ```bash
281
- curl -X POST http://localhost:3000/v1/tx/message \
290
+ curl http://localhost:3776/api/v1/ping
291
+ ```
292
+ 4. Trigger a transactional email (authenticated):
293
+ ```bash
294
+ curl -X POST http://localhost:3776/api/v1/tx/message \
282
295
  -H 'Content-Type: application/json' \
283
- -H 'X-API-Token: <your 32-char token>' \
296
+ -H 'Authorization: Bearer apikey-<your token>' \
284
297
  -d '{
285
- "user": "myorg",
286
298
  "domain": "myorg.com",
287
- "slug": "welcome",
288
- "to": "new.user@myorg.com",
289
- "variables": {"first_name": "Kai", "cta_url": "https://myorg.com/confirm"}
299
+ "name": "welcome",
300
+ "locale": "en",
301
+ "rcpt": "new.user@myorg.com",
302
+ "vars": {
303
+ "first_name": "Kai",
304
+ "cta_url": "https://myorg.com/confirm",
305
+ "support_url": "https://myorg.com/support"
306
+ }
307
+ }'
308
+ ```
309
+ 5. Submit the contact form the same way your frontend will post (public endpoint). Using `form_key` is preferred:
310
+ ```bash
311
+ curl -X POST http://localhost:3776/api/v1/form/message \
312
+ -H 'Content-Type: application/json' \
313
+ -d '{
314
+ "form_key": "<your form_key>",
315
+ "secret": "s3cret",
316
+ "name": "Kai",
317
+ "email": "kai@myorg.com",
318
+ "message": "Hello from the contact form"
290
319
  }'
291
320
  ```
292
- 4. Trigger the contact form template the same way your frontend will post to `/v1/form/message` (Supply `form_id` or `idname` of `contact`). With `DB_AUTO_RELOAD=1`, editing the templates or assets is as simple as saving the file.
321
+
322
+ With `DB_AUTO_RELOAD=1`, editing templates or assets is as simple as saving the file.
293
323
 
294
324
  You now have a clean, self-contained configuration for MyOrg that inherits Mail Magic behaviour while keeping templates, partials, and assets under version control in a dedicated folder.
325
+
326
+ ---
327
+
328
+ ## 6. Optional: Recipient allowlist for public forms (`recipient_idname`)
329
+
330
+ If you have a public form where the frontend must select a recipient (for example "send a message to a journalist"),
331
+ avoid shipping raw email addresses client-side.
332
+
333
+ Instead:
334
+
335
+ 1. Configure recipients (authenticated) with `POST /api/v1/form/recipient`.
336
+ 2. Submit public forms with `recipient_idname`.
337
+
338
+ Example (domain-wide mapping):
339
+
340
+ ```bash
341
+ curl -X POST http://localhost:3776/api/v1/form/recipient \
342
+ -H 'Content-Type: application/json' \
343
+ -H 'Authorization: Bearer apikey-<your token>' \
344
+ -d '{
345
+ "domain": "myorg.com",
346
+ "idname": "desk",
347
+ "email": "News Desk <desk@myorg.com>"
348
+ }'
349
+ ```
350
+
351
+ Example (public submit):
352
+
353
+ ```bash
354
+ curl -X POST http://localhost:3776/api/v1/form/message \
355
+ -H 'Content-Type: application/json' \
356
+ -d '{
357
+ "form_key": "<your form_key>",
358
+ "recipient_idname": "desk",
359
+ "name": "Kai",
360
+ "email": "kai@myorg.com",
361
+ "message": "Hello"
362
+ }'
363
+ ```
364
+
365
+ Mappings can also be scoped to a specific form by supplying `form_key` on the `/form/recipient` upsert. Form-scoped
366
+ mappings override domain-wide mappings for the same `idname`.
367
+
368
+ ---
369
+
370
+ ## 7. Optional: CAPTCHA and rate limiting for public forms
371
+
372
+ If the public form endpoint is a spam/volume target, enable one or more of these:
373
+
374
+ - `FORM_RATE_LIMIT_WINDOW_SEC` + `FORM_RATE_LIMIT_MAX`
375
+ - `FORM_MAX_ATTACHMENTS` and `UPLOAD_MAX`
376
+ - CAPTCHA: set `FORM_CAPTCHA_SECRET` + `FORM_CAPTCHA_PROVIDER`
377
+
378
+ Per form, you can also set `captcha_required=true` when storing/updating the form template via the API (`POST
379
+ /api/v1/form/template`).
package/dist/index.js CHANGED
@@ -4,7 +4,6 @@ import { FormAPI } from './api/forms.js';
4
4
  import { MailerAPI } from './api/mailer.js';
5
5
  import { mailApiServer } from './server.js';
6
6
  import { mailStore } from './store/store.js';
7
- const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
8
7
  function normalizeRoute(value, fallback = '') {
9
8
  if (!value) {
10
9
  return fallback;
package/dist/models/db.js CHANGED
@@ -72,8 +72,9 @@ export async function init_api_db(db, store) {
72
72
  as: 'domain'
73
73
  });
74
74
  await db.query('PRAGMA foreign_keys = OFF');
75
- store.print_debug(`Force alter tables: ${store.env.DB_FORCE_SYNC}`);
76
- await db.sync({ alter: true, force: store.env.DB_FORCE_SYNC });
75
+ const alter = Boolean(store.env.DB_SYNC_ALTER);
76
+ store.print_debug(`DB sync: alter=${alter} force=${store.env.DB_FORCE_SYNC}`);
77
+ await db.sync({ alter, force: store.env.DB_FORCE_SYNC });
77
78
  await db.query('PRAGMA foreign_keys = ON');
78
79
  await importData(store);
79
80
  try {
@@ -1,9 +1,10 @@
1
1
  import { Model, DataTypes } from 'sequelize';
2
2
  import { z } from 'zod';
3
+ const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
3
4
  export const api_domain_schema = z.object({
4
5
  domain_id: z.number().int().nonnegative(),
5
6
  user_id: z.number().int().nonnegative(),
6
- name: z.string().min(1),
7
+ name: z.string().min(1).regex(DOMAIN_PATTERN, 'Invalid domain name'),
7
8
  sender: z.string().default(''),
8
9
  locale: z.string().default(''),
9
10
  is_default: z.boolean().default(false)
@@ -6,7 +6,7 @@ export const envOptions = defineEnvOptions({
6
6
  default: 'development'
7
7
  },
8
8
  API_PORT: {
9
- description: 'Defines the port on which the app listens. Default 3780',
9
+ description: 'Defines the port on which the app listens. Default 3776',
10
10
  default: '3776',
11
11
  type: 'number'
12
12
  },
@@ -15,15 +15,20 @@ export const envOptions = defineEnvOptions({
15
15
  default: '0.0.0.0'
16
16
  },
17
17
  DB_AUTO_RELOAD: {
18
- description: 'Reload init-data.db automatically on change',
18
+ description: 'Reload init-data.json automatically on change',
19
19
  type: 'boolean',
20
- default: true
20
+ default: false
21
21
  },
22
22
  DB_FORCE_SYNC: {
23
- description: 'Whether to force sync on table definitions (ALTER TABLE)',
23
+ description: 'Drop and recreate database tables on startup (DANGEROUS)',
24
24
  type: 'boolean',
25
25
  default: false
26
26
  },
27
+ DB_SYNC_ALTER: {
28
+ description: 'Alter existing tables on startup to match models (requires write access to the DB)',
29
+ type: 'boolean',
30
+ default: true
31
+ },
27
32
  API_URL: {
28
33
  description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
29
34
  default: 'http://localhost:3776'
package/dist/util.js CHANGED
@@ -10,7 +10,7 @@ import { api_user } from './models/user.js';
10
10
  *
11
11
  * Examples:
12
12
  * normalizeSlug("Hello World!") -> "hello-world"
13
- * normalizeSlug(" Áccêntš ") -> "ccnt"
13
+ * normalizeSlug(" Áccêntš ") -> "cc-nt"
14
14
  * normalizeSlug("My--Slug__Test") -> "my-slug__test"
15
15
  */
16
16
  export function normalizeSlug(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic",
3
- "version": "1.0.23",
3
+ "version": "1.0.32",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {