@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 +40 -0
- package/README.md +202 -78
- package/TUTORIAL.MD +113 -28
- package/dist/index.js +0 -1
- package/dist/models/db.js +3 -2
- package/dist/models/domain.js +2 -1
- package/dist/store/envloader.js +9 -4
- package/dist/util.js +1 -1
- package/package.json +1 -1
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
|
|
4
|
-
REST API built on `@technomoron/api-server-base`, persists data with Sequelize/SQLite
|
|
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
|
-
-
|
|
10
|
-
-
|
|
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
|
-
-
|
|
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
|
-
##
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
205
|
+
## Database Notes
|
|
87
206
|
|
|
88
|
-
-
|
|
89
|
-
-
|
|
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
|
-
##
|
|
211
|
+
## Admin UI / Swagger
|
|
92
212
|
|
|
93
|
-
|
|
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
|
-
##
|
|
217
|
+
## Available Scripts (Repository)
|
|
96
218
|
|
|
97
|
-
|
|
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: hot
|
|
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/
|
|
34
|
-
"$CONFIG_ROOT"/myorg.com/form-template/
|
|
35
|
-
"$CONFIG_ROOT"/myorg.com/tx-template/
|
|
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
|
|
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
|
-
"
|
|
95
|
-
"
|
|
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": "
|
|
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
|
|
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/
|
|
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 `
|
|
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.
|
|
288
|
+
3. Verify the server is reachable:
|
|
280
289
|
```bash
|
|
281
|
-
curl
|
|
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 '
|
|
296
|
+
-H 'Authorization: Bearer apikey-<your token>' \
|
|
284
297
|
-d '{
|
|
285
|
-
"user": "myorg",
|
|
286
298
|
"domain": "myorg.com",
|
|
287
|
-
"
|
|
288
|
-
"
|
|
289
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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 {
|
package/dist/models/domain.js
CHANGED
|
@@ -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)
|
package/dist/store/envloader.js
CHANGED
|
@@ -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
|
|
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.
|
|
18
|
+
description: 'Reload init-data.json automatically on change',
|
|
19
19
|
type: 'boolean',
|
|
20
|
-
default:
|
|
20
|
+
default: false
|
|
21
21
|
},
|
|
22
22
|
DB_FORCE_SYNC: {
|
|
23
|
-
description: '
|
|
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š ") -> "
|
|
13
|
+
* normalizeSlug(" Áccêntš ") -> "cc-nt"
|
|
14
14
|
* normalizeSlug("My--Slug__Test") -> "my-slug__test"
|
|
15
15
|
*/
|
|
16
16
|
export function normalizeSlug(input) {
|