@technomoron/mail-magic 1.0.32 → 1.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGES +18 -0
  2. package/README.md +213 -122
  3. package/dist/api/assets.js +9 -56
  4. package/dist/api/auth.js +1 -12
  5. package/dist/api/form-replyto.js +1 -0
  6. package/dist/api/form-submission.js +1 -0
  7. package/dist/api/forms.js +114 -474
  8. package/dist/api/mailer.js +1 -1
  9. package/dist/bin/mail-magic.js +2 -2
  10. package/dist/index.js +30 -18
  11. package/dist/models/db.js +5 -5
  12. package/dist/models/domain.js +16 -8
  13. package/dist/models/form.js +111 -40
  14. package/dist/models/init.js +44 -74
  15. package/dist/models/recipient.js +12 -8
  16. package/dist/models/txmail.js +24 -28
  17. package/dist/models/user.js +14 -10
  18. package/dist/server.js +1 -1
  19. package/dist/store/store.js +53 -22
  20. package/dist/swagger.js +107 -0
  21. package/dist/util/captcha.js +24 -0
  22. package/dist/util/email.js +19 -0
  23. package/dist/util/form-replyto.js +44 -0
  24. package/dist/util/form-submission.js +95 -0
  25. package/dist/util/forms.js +431 -0
  26. package/dist/util/paths.js +41 -0
  27. package/dist/util/ratelimit.js +48 -0
  28. package/dist/util/uploads.js +48 -0
  29. package/dist/util/utils.js +151 -0
  30. package/dist/util.js +7 -127
  31. package/docs/config-example/example.test/assets/files/banner.png +1 -0
  32. package/docs/config-example/example.test/assets/images/logo.png +1 -0
  33. package/docs/config-example/example.test/form-template/base.njk +6 -0
  34. package/docs/config-example/example.test/form-template/contact.njk +9 -0
  35. package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
  36. package/docs/config-example/example.test/tx-template/base.njk +10 -0
  37. package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
  38. package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
  39. package/docs/config-example/init-data.json +57 -0
  40. package/docs/form-security.md +194 -0
  41. package/docs/swagger/openapi.json +1321 -0
  42. package/{TUTORIAL.MD → docs/tutorial.md} +24 -15
  43. package/package.json +3 -3
@@ -0,0 +1,151 @@
1
+ import { api_domain } from '../models/domain.js';
2
+ import { api_user } from '../models/user.js';
3
+ /**
4
+ * Normalize a string into a safe identifier for slugs, filenames, etc.
5
+ *
6
+ * - Lowercases all characters
7
+ * - Replaces any character that is not `a-z`, `0-9`, `-`, '.' or `_` with `-`
8
+ * - Collapses multiple consecutive dashes into one
9
+ * - Trims leading and trailing dashes
10
+ *
11
+ * Examples:
12
+ * normalizeSlug("Hello World!") -> "hello-world"
13
+ * normalizeSlug(" Áccêntš ") -> "cc-nt"
14
+ * normalizeSlug("My--Slug__Test") -> "my-slug__test"
15
+ */
16
+ export function normalizeSlug(input) {
17
+ if (!input) {
18
+ return '';
19
+ }
20
+ return input
21
+ .trim()
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9-_\.]/g, '-')
24
+ .replace(/--+/g, '-') // collapse multiple dashes
25
+ .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes
26
+ }
27
+ export async function user_and_domain(domain_id) {
28
+ const domain = await api_domain.findByPk(domain_id);
29
+ if (!domain) {
30
+ throw new Error(`Unable to look up domain ${domain_id}`);
31
+ }
32
+ const user = await api_user.findByPk(domain.user_id);
33
+ if (!user) {
34
+ throw new Error(`Unable to look up user ${domain.user_id}`);
35
+ }
36
+ return { user, domain };
37
+ }
38
+ function collectHeaderIps(header) {
39
+ if (!header) {
40
+ return [];
41
+ }
42
+ if (Array.isArray(header)) {
43
+ return header
44
+ .join(',')
45
+ .split(',')
46
+ .map((ip) => ip.trim())
47
+ .filter(Boolean);
48
+ }
49
+ return header
50
+ .split(',')
51
+ .map((ip) => ip.trim())
52
+ .filter(Boolean);
53
+ }
54
+ function resolveHeader(headers, key) {
55
+ const direct = headers[key];
56
+ const alt = headers[key.toLowerCase()];
57
+ const value = direct ?? alt;
58
+ if (typeof value === 'string' || Array.isArray(value)) {
59
+ return value;
60
+ }
61
+ return undefined;
62
+ }
63
+ export function buildRequestMeta(rawReq) {
64
+ const req = (rawReq ?? {});
65
+ const headers = req.headers ?? {};
66
+ const ips = [];
67
+ ips.push(...collectHeaderIps(resolveHeader(headers, 'x-forwarded-for')));
68
+ const realIp = resolveHeader(headers, 'x-real-ip');
69
+ if (typeof realIp === 'string' && realIp.trim()) {
70
+ ips.push(realIp.trim());
71
+ }
72
+ const cfIp = resolveHeader(headers, 'cf-connecting-ip');
73
+ if (typeof cfIp === 'string' && cfIp.trim()) {
74
+ ips.push(cfIp.trim());
75
+ }
76
+ const fastlyIp = resolveHeader(headers, 'fastly-client-ip');
77
+ if (typeof fastlyIp === 'string' && fastlyIp.trim()) {
78
+ ips.push(fastlyIp.trim());
79
+ }
80
+ if (req.ip && req.ip.trim()) {
81
+ ips.push(req.ip.trim());
82
+ }
83
+ const remoteAddress = req.socket?.remoteAddress;
84
+ if (remoteAddress) {
85
+ ips.push(remoteAddress);
86
+ }
87
+ const uniqueIps = ips.filter((ip, index) => ips.indexOf(ip) === index);
88
+ const clientIp = uniqueIps[0] || '';
89
+ return {
90
+ client_ip: clientIp,
91
+ received_at: new Date().toISOString(),
92
+ ip_chain: uniqueIps
93
+ };
94
+ }
95
+ export function decodeComponent(value) {
96
+ if (!value) {
97
+ return '';
98
+ }
99
+ const decoded = Array.isArray(value) ? (value[0] ?? '') : value;
100
+ if (!decoded) {
101
+ return '';
102
+ }
103
+ try {
104
+ return decodeURIComponent(decoded);
105
+ }
106
+ catch {
107
+ return decoded;
108
+ }
109
+ }
110
+ export function getBodyValue(body, ...keys) {
111
+ for (const key of keys) {
112
+ const value = body[key];
113
+ if (Array.isArray(value) && value.length > 0) {
114
+ return String(value[0]);
115
+ }
116
+ if (value !== undefined && value !== null) {
117
+ return String(value);
118
+ }
119
+ }
120
+ return '';
121
+ }
122
+ export function normalizeBoolean(value) {
123
+ if (typeof value === 'boolean') {
124
+ return value;
125
+ }
126
+ if (typeof value === 'number') {
127
+ return value !== 0;
128
+ }
129
+ const normalized = String(value ?? '')
130
+ .trim()
131
+ .toLowerCase();
132
+ return ['true', '1', 'yes', 'on'].includes(normalized);
133
+ }
134
+ export function sendFileAsync(res, file, options) {
135
+ return new Promise((resolve, reject) => {
136
+ const cb = (err) => {
137
+ if (err) {
138
+ reject(err instanceof Error ? err : new Error(String(err)));
139
+ }
140
+ else {
141
+ resolve();
142
+ }
143
+ };
144
+ if (options !== undefined) {
145
+ // Express will set Cache-Control based on `maxAge` etc; callers can still override.
146
+ res.sendFile(file, options, cb);
147
+ return;
148
+ }
149
+ res.sendFile(file, cb);
150
+ });
151
+ }
package/dist/util.js CHANGED
@@ -1,127 +1,7 @@
1
- import { api_domain } from './models/domain.js';
2
- import { api_user } from './models/user.js';
3
- /**
4
- * Normalize a string into a safe identifier for slugs, filenames, etc.
5
- *
6
- * - Lowercases all characters
7
- * - Replaces any character that is not `a-z`, `0-9`, `-`, '.' or `_` with `-`
8
- * - Collapses multiple consecutive dashes into one
9
- * - Trims leading and trailing dashes
10
- *
11
- * Examples:
12
- * normalizeSlug("Hello World!") -> "hello-world"
13
- * normalizeSlug(" Áccêntš ") -> "cc-nt"
14
- * normalizeSlug("My--Slug__Test") -> "my-slug__test"
15
- */
16
- export function normalizeSlug(input) {
17
- if (!input) {
18
- return '';
19
- }
20
- return input
21
- .trim()
22
- .toLowerCase()
23
- .replace(/[^a-z0-9-_\.]/g, '-')
24
- .replace(/--+/g, '-') // collapse multiple dashes
25
- .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes
26
- }
27
- export async function user_and_domain(domain_id) {
28
- const domain = await api_domain.findByPk(domain_id);
29
- if (!domain) {
30
- throw new Error(`Unable to look up domain ${domain_id}`);
31
- }
32
- const user = await api_user.findByPk(domain.user_id);
33
- if (!user) {
34
- throw new Error(`Unable to look up user ${domain.user_id}`);
35
- }
36
- return { user, domain };
37
- }
38
- function collectHeaderIps(header) {
39
- if (!header) {
40
- return [];
41
- }
42
- if (Array.isArray(header)) {
43
- return header
44
- .join(',')
45
- .split(',')
46
- .map((ip) => ip.trim())
47
- .filter(Boolean);
48
- }
49
- return header
50
- .split(',')
51
- .map((ip) => ip.trim())
52
- .filter(Boolean);
53
- }
54
- function resolveHeader(headers, key) {
55
- const direct = headers[key];
56
- const alt = headers[key.toLowerCase()];
57
- const value = direct ?? alt;
58
- if (typeof value === 'string' || Array.isArray(value)) {
59
- return value;
60
- }
61
- return undefined;
62
- }
63
- export function buildRequestMeta(rawReq) {
64
- const req = (rawReq ?? {});
65
- const headers = req.headers ?? {};
66
- const ips = [];
67
- ips.push(...collectHeaderIps(resolveHeader(headers, 'x-forwarded-for')));
68
- const realIp = resolveHeader(headers, 'x-real-ip');
69
- if (typeof realIp === 'string' && realIp.trim()) {
70
- ips.push(realIp.trim());
71
- }
72
- const cfIp = resolveHeader(headers, 'cf-connecting-ip');
73
- if (typeof cfIp === 'string' && cfIp.trim()) {
74
- ips.push(cfIp.trim());
75
- }
76
- const fastlyIp = resolveHeader(headers, 'fastly-client-ip');
77
- if (typeof fastlyIp === 'string' && fastlyIp.trim()) {
78
- ips.push(fastlyIp.trim());
79
- }
80
- if (req.ip && req.ip.trim()) {
81
- ips.push(req.ip.trim());
82
- }
83
- const remoteAddress = req.socket?.remoteAddress;
84
- if (remoteAddress) {
85
- ips.push(remoteAddress);
86
- }
87
- const uniqueIps = ips.filter((ip, index) => ips.indexOf(ip) === index);
88
- const clientIp = uniqueIps[0] || '';
89
- return {
90
- client_ip: clientIp,
91
- received_at: new Date().toISOString(),
92
- ip_chain: uniqueIps
93
- };
94
- }
95
- export function decodeComponent(value) {
96
- if (!value) {
97
- return '';
98
- }
99
- const decoded = Array.isArray(value) ? (value[0] ?? '') : value;
100
- if (!decoded) {
101
- return '';
102
- }
103
- try {
104
- return decodeURIComponent(decoded);
105
- }
106
- catch {
107
- return decoded;
108
- }
109
- }
110
- export function sendFileAsync(res, file, options) {
111
- return new Promise((resolve, reject) => {
112
- const cb = (err) => {
113
- if (err) {
114
- reject(err instanceof Error ? err : new Error(String(err)));
115
- }
116
- else {
117
- resolve();
118
- }
119
- };
120
- if (options !== undefined) {
121
- // Express will set Cache-Control based on `maxAge` etc; callers can still override.
122
- res.sendFile(file, options, cb);
123
- return;
124
- }
125
- res.sendFile(file, cb);
126
- });
127
- }
1
+ export * from './util/utils.js';
2
+ export * from './util/email.js';
3
+ export * from './util/paths.js';
4
+ export * from './util/uploads.js';
5
+ export * from './util/form-replyto.js';
6
+ export * from './util/form-submission.js';
7
+ export * from './util/forms.js';
@@ -0,0 +1 @@
1
+ example-banner-bytes
@@ -0,0 +1 @@
1
+ example-logo-bytes
@@ -0,0 +1,6 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ {% block body %}{% endblock %}
5
+ </body>
6
+ </html>
@@ -0,0 +1,9 @@
1
+ {% extends "base.njk" %}
2
+
3
+ {% block body %}
4
+ {% include "partials/fields.njk" %}
5
+
6
+ <p>From IP: {{ _meta_.client_ip }}</p>
7
+
8
+ <img src="asset('images/logo.png', true)" alt="Logo" />
9
+ {% endblock %}
@@ -0,0 +1,3 @@
1
+ <p>Name: {{ _fields_.name }}</p>
2
+ <p>Email: {{ _fields_.email }}</p>
3
+ <p>Message: {{ _fields_.message }}</p>
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>{{ _vars_.title or 'Mail Magic' }}</title>
6
+ </head>
7
+ <body>
8
+ {% block body %}{% endblock %}
9
+ </body>
10
+ </html>
@@ -0,0 +1 @@
1
+ <h1>{{ _vars_.heading or 'Hello from Mail Magic' }}</h1>
@@ -0,0 +1,10 @@
1
+ {% extends "base.njk" %}
2
+
3
+ {% block body %}
4
+ {% include "partials/header.njk" %}
5
+
6
+ <p>Hello {{ _vars_.first_name or _rcpt_email_ }}!</p>
7
+
8
+ <img src="asset('images/logo.png', true)" alt="Logo" />
9
+ <img src="asset('files/banner.png')" alt="Banner" />
10
+ {% endblock %}
@@ -0,0 +1,57 @@
1
+ {
2
+ "user": [
3
+ {
4
+ "user_id": 1,
5
+ "idname": "example",
6
+ "token": "example-token",
7
+ "name": "Example User",
8
+ "email": "noreply@example.test",
9
+ "domain": 1,
10
+ "locale": ""
11
+ }
12
+ ],
13
+ "domain": [
14
+ {
15
+ "domain_id": 1,
16
+ "user_id": 1,
17
+ "name": "example.test",
18
+ "sender": "Example <noreply@example.test>",
19
+ "locale": "",
20
+ "is_default": true
21
+ }
22
+ ],
23
+ "template": [
24
+ {
25
+ "template_id": 1,
26
+ "user_id": 1,
27
+ "domain_id": 1,
28
+ "name": "welcome",
29
+ "locale": "",
30
+ "template": "",
31
+ "filename": "",
32
+ "sender": "Example <noreply@example.test>",
33
+ "subject": "Welcome from Mail Magic",
34
+ "slug": "",
35
+ "files": []
36
+ }
37
+ ],
38
+ "form": [
39
+ {
40
+ "form_id": 1,
41
+ "form_key": "example-contact-form",
42
+ "user_id": 1,
43
+ "domain_id": 1,
44
+ "locale": "",
45
+ "idname": "contact",
46
+ "sender": "Example Forms <forms@example.test>",
47
+ "recipient": "owner@example.test",
48
+ "subject": "New contact form submission",
49
+ "template": "",
50
+ "filename": "",
51
+ "slug": "",
52
+ "secret": "s3cret",
53
+ "captcha_required": false,
54
+ "files": []
55
+ }
56
+ ]
57
+ }
@@ -0,0 +1,194 @@
1
+ # Form Security (Spam + Abuse Mitigation)
2
+
3
+ This document describes how to operate the public form submission endpoint safely.
4
+
5
+ ## Public Endpoint Contract
6
+
7
+ Endpoint:
8
+
9
+ - `POST /api/v1/form/message` (no auth)
10
+
11
+ Required fields:
12
+
13
+ - `_mm_form_key` (string)
14
+
15
+ Optional routing fields:
16
+
17
+ - `_mm_recipients` (array of recipient `idname`s, or a comma-separated string)
18
+
19
+ Optional anti-abuse field:
20
+
21
+ - CAPTCHA token field (provider-native):
22
+ - `cf-turnstile-response` (Turnstile)
23
+ - `h-captcha-response` (hCaptcha)
24
+ - `g-recaptcha-response` (reCAPTCHA)
25
+ - `captcha` (generic/legacy)
26
+
27
+ Other fields:
28
+
29
+ - Any other fields are allowed and are exposed to templates as `_fields_`.
30
+
31
+ Non-system fields:
32
+
33
+ - Any other non-`_mm_*` fields are accepted as user fields and exposed to templates as `_fields_` verbatim.
34
+ - If the form template has `allowed_fields` configured, `_fields_` is filtered to that allowlist, plus these
35
+ always-allowed fields: `email`, `name`, `first_name`, `last_name` (so Reply-To extraction still works).
36
+
37
+ Ignored legacy inputs:
38
+
39
+ - The server does not use `domain`, `formid`, `secret`, `recipient`, `recipient_idname`, `replyto` (or casing variants)
40
+ for routing/auth. If they are submitted, they are treated as normal user fields (unless filtered by `allowed_fields`).
41
+
42
+ CAPTCHA token fields are accepted exactly as the providers submit them (no wrapper/rename).
43
+
44
+ Security goal:
45
+
46
+ - Treat `form_key` as the only public identifier needed to locate the form.
47
+ - Prevent “open relay” style abuse by allowing only recipient `idname`s (resolved server-side) instead of raw email
48
+ addresses.
49
+ - Prevent client-controlled secrets and legacy fields from widening the attack surface.
50
+
51
+ ## Recipient Allowlist and Reply-To
52
+
53
+ Mail Magic supports a recipient allowlist stored server-side via the authenticated endpoint:
54
+
55
+ - `POST /api/v1/form/recipient` (auth required)
56
+
57
+ Each recipient mapping has an `idname` and an email address. Mappings can be:
58
+
59
+ - Form-scoped (provide `form_key` when upserting the mapping)
60
+ - Domain-wide fallback (omit `form_key`)
61
+
62
+ Public submissions can then request routing by specifying:
63
+
64
+ - `_mm_recipients: ["alice", "desk"]`
65
+
66
+ If `_mm_recipients` is omitted, the form’s stored default recipient is used.
67
+
68
+ Reply-To:
69
+
70
+ - Reply-To behavior is configured per form (stored with the form template).
71
+
72
+ Fields on the form template:
73
+
74
+ - `replyto_from_fields` (boolean): when enabled, derive Reply-To from the submitted fields:
75
+ - `email`
76
+ - optional `name` or `first_name` + `last_name`
77
+ - `replyto_email` (string): forced reply-to mailbox used when extraction is disabled, or as a fallback when extraction
78
+ fails.
79
+ - `allowed_fields` (string[]): optional allowlist of field names exposed to templates as `_fields_`. When set, any
80
+ submitted fields not listed are ignored for template rendering (and for reply-to extraction).
81
+
82
+ Precedence:
83
+
84
+ - If `replyto_from_fields=true`: use extracted Reply-To if possible, otherwise fall back to `replyto_email` (if set).
85
+ - If `replyto_from_fields=false`: use `replyto_email` (if set).
86
+ - Otherwise: omit Reply-To.
87
+
88
+ ## CAPTCHA
89
+
90
+ CAPTCHA is verified server-side.
91
+
92
+ Configuration (server environment):
93
+
94
+ - `FORM_CAPTCHA_PROVIDER`: `turnstile` | `hcaptcha` | `recaptcha`
95
+ - `FORM_CAPTCHA_SECRET`: provider secret key (enables verification when set)
96
+ - `FORM_CAPTCHA_REQUIRED`: when `true`, require CAPTCHA tokens for all form submissions
97
+
98
+ Per-form configuration (authenticated template upsert):
99
+
100
+ - `captcha_required=true` on `POST /api/v1/form/template` to require CAPTCHA for that form.
101
+
102
+ Client integration contract:
103
+
104
+ - CAPTCHA token fields are accepted exactly as the providers submit them. Do not wrap or rename them.
105
+
106
+ Examples:
107
+
108
+ ```json
109
+ { "_mm_form_key": "abc", "cf-turnstile-response": "<turnstile token>", "name": "Ada" }
110
+ ```
111
+
112
+ ```json
113
+ { "_mm_form_key": "abc", "h-captcha-response": "<hcaptcha token>", "name": "Ada" }
114
+ ```
115
+
116
+ ```json
117
+ { "_mm_form_key": "abc", "g-recaptcha-response": "<recaptcha token>", "name": "Ada" }
118
+ ```
119
+
120
+ Operational notes:
121
+
122
+ - If CAPTCHA is required but `FORM_CAPTCHA_SECRET` is missing, the server returns `500`.
123
+ - If CAPTCHA is required and the provider token field is missing, the server returns `403`.
124
+ - If verification fails, the server returns `403`.
125
+
126
+ ## Rate Limiting
127
+
128
+ Mail Magic has an optional in-memory fixed-window limiter on the public form endpoint:
129
+
130
+ - `FORM_RATE_LIMIT_WINDOW_SEC`: window size in seconds
131
+ - `FORM_RATE_LIMIT_MAX`: max requests per client IP per window (`0` disables rate limiting)
132
+
133
+ Important limitations:
134
+
135
+ - It is per-process memory. If you run multiple instances, limits do not aggregate.
136
+ - Client IP is derived from request metadata; if you are not behind a trusted reverse proxy that normalizes headers,
137
+ clients can spoof IP-related headers.
138
+
139
+ Recommendation:
140
+
141
+ - Keep the built-in limiter as a “last line of defense”.
142
+ - Enforce a real limiter at the edge (CDN/WAF/reverse proxy) for stronger protection.
143
+
144
+ ## Attachments and Upload Limits
145
+
146
+ Attachments are a common abuse vector.
147
+
148
+ Controls:
149
+
150
+ - `UPLOAD_MAX` (bytes): max size per uploaded file (enforced by the server’s multipart handling)
151
+ - `FORM_MAX_ATTACHMENTS`: max number of uploaded files (`-1` unlimited, `0` disables attachments)
152
+ - `FORM_KEEP_UPLOADS`: when `false`, uploaded files are deleted after processing (best-effort), even on failures
153
+
154
+ Recommendations:
155
+
156
+ - If you do not need attachments, set `FORM_MAX_ATTACHMENTS=0`.
157
+ - Set a conservative `UPLOAD_MAX` (and enforce matching limits at your reverse proxy).
158
+ - Monitor disk usage for your upload staging directory.
159
+
160
+ ## Reverse Proxy / Edge Hardening
161
+
162
+ You should run the server behind a reverse proxy (or CDN) and apply:
163
+
164
+ - Request body size limits.
165
+ - Rate limits per IP.
166
+ - Bot protection / WAF rules on `POST /api/v1/form/message`.
167
+ - Header normalization: strip client-provided `X-Forwarded-For` and set it yourself.
168
+
169
+ Example Nginx ideas (sketch, not drop-in):
170
+
171
+ ```nginx
172
+ # Limit body size (align with UPLOAD_MAX and your attachment policy).
173
+ client_max_body_size 2m;
174
+
175
+ # Basic rate limiting.
176
+ limit_req_zone $binary_remote_addr zone=form_rate:10m rate=10r/m;
177
+
178
+ location /api/v1/form/message {
179
+ limit_req zone=form_rate burst=20 nodelay;
180
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
181
+ proxy_set_header X-Real-IP $remote_addr;
182
+ proxy_pass http://127.0.0.1:3776;
183
+ }
184
+ ```
185
+
186
+ ## Treat `form_key` as Sensitive
187
+
188
+ `form_key` is the public identifier for a form. If it is leaked, attackers can submit spam to that form.
189
+
190
+ Recommendations:
191
+
192
+ - Use long, random `form_key`s (Mail Magic generates them automatically when creating/upserting a form template).
193
+ - Rotate `form_key` if you suspect it has leaked.
194
+ - Don’t publish `form_key` in places you cannot control (logs, public repos, client-side error reporting).