@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.
- package/CHANGES +18 -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 +1 -0
- package/dist/api/form-submission.js +1 -0
- package/dist/api/forms.js +114 -474
- 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 +111 -40
- package/dist/models/init.js +44 -74
- package/dist/models/recipient.js +12 -8
- package/dist/models/txmail.js +24 -28
- 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/form-replyto.js +44 -0
- package/dist/util/form-submission.js +95 -0
- package/dist/util/forms.js +431 -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 +7 -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
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
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 @@
|
|
|
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).
|